@supaku/agentfactory-server 0.7.9 → 0.7.10
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/src/agent-tracking.d.ts +80 -0
- package/dist/src/agent-tracking.d.ts.map +1 -1
- package/dist/src/agent-tracking.js +119 -0
- package/dist/src/governor-storage.d.ts +28 -0
- package/dist/src/governor-storage.d.ts.map +1 -0
- package/dist/src/governor-storage.js +52 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/processing-state-storage.d.ts +38 -0
- package/dist/src/processing-state-storage.d.ts.map +1 -0
- package/dist/src/processing-state-storage.js +61 -0
- package/dist/src/workflow-state-integration.test.d.ts +2 -0
- package/dist/src/workflow-state-integration.test.d.ts.map +1 -0
- package/dist/src/workflow-state-integration.test.js +281 -0
- package/dist/src/workflow-state.test.d.ts +2 -0
- package/dist/src/workflow-state.test.d.ts.map +1 -0
- package/dist/src/workflow-state.test.js +113 -0
- package/package.json +3 -3
|
@@ -28,6 +28,86 @@ export interface QAAttemptRecord {
|
|
|
28
28
|
reason?: string;
|
|
29
29
|
}>;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Escalation strategy computed deterministically from cycleCount.
|
|
33
|
+
* No LLM in the decision loop — pure function.
|
|
34
|
+
*/
|
|
35
|
+
export type EscalationStrategy = 'normal' | 'context-enriched' | 'decompose' | 'escalate-human';
|
|
36
|
+
/**
|
|
37
|
+
* Workflow phase types
|
|
38
|
+
*/
|
|
39
|
+
export type WorkflowPhase = 'development' | 'qa' | 'refinement' | 'acceptance';
|
|
40
|
+
/**
|
|
41
|
+
* Record of a single phase attempt within a workflow cycle
|
|
42
|
+
*/
|
|
43
|
+
export interface PhaseRecord {
|
|
44
|
+
attempt: number;
|
|
45
|
+
sessionId?: string;
|
|
46
|
+
startedAt: number;
|
|
47
|
+
completedAt?: number;
|
|
48
|
+
result?: 'passed' | 'failed' | 'unknown';
|
|
49
|
+
failureReason?: string;
|
|
50
|
+
costUsd?: number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Cross-phase workflow state for an issue.
|
|
54
|
+
* Tracks the full dev-QA-rejected cycle count and accumulated failure context.
|
|
55
|
+
* Higher-level overlay on top of the existing QAAttemptRecord.
|
|
56
|
+
*/
|
|
57
|
+
export interface WorkflowState {
|
|
58
|
+
issueId: string;
|
|
59
|
+
issueIdentifier: string;
|
|
60
|
+
cycleCount: number;
|
|
61
|
+
phases: {
|
|
62
|
+
development: PhaseRecord[];
|
|
63
|
+
qa: PhaseRecord[];
|
|
64
|
+
refinement: PhaseRecord[];
|
|
65
|
+
acceptance: PhaseRecord[];
|
|
66
|
+
};
|
|
67
|
+
strategy: EscalationStrategy;
|
|
68
|
+
failureSummary: string | null;
|
|
69
|
+
createdAt: number;
|
|
70
|
+
updatedAt: number;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Compute escalation strategy deterministically from cycle count.
|
|
74
|
+
* Pure function — no side effects, no LLM.
|
|
75
|
+
*/
|
|
76
|
+
export declare function computeStrategy(cycleCount: number): EscalationStrategy;
|
|
77
|
+
/**
|
|
78
|
+
* Get the workflow state for an issue
|
|
79
|
+
*/
|
|
80
|
+
export declare function getWorkflowState(issueId: string): Promise<WorkflowState | null>;
|
|
81
|
+
/**
|
|
82
|
+
* Update (partial merge) the workflow state for an issue.
|
|
83
|
+
* Creates the state if it doesn't exist.
|
|
84
|
+
*/
|
|
85
|
+
export declare function updateWorkflowState(issueId: string, partial: Partial<Omit<WorkflowState, 'issueId'>> & {
|
|
86
|
+
issueIdentifier?: string;
|
|
87
|
+
}): Promise<WorkflowState>;
|
|
88
|
+
/**
|
|
89
|
+
* Record a phase attempt in the workflow state
|
|
90
|
+
*/
|
|
91
|
+
export declare function recordPhaseAttempt(issueId: string, phase: WorkflowPhase, record: PhaseRecord): Promise<WorkflowState>;
|
|
92
|
+
/**
|
|
93
|
+
* Increment the dev-QA-rejected cycle count and recompute strategy.
|
|
94
|
+
* Called when QA or acceptance fails and triggers a Rejected transition.
|
|
95
|
+
*/
|
|
96
|
+
export declare function incrementCycleCount(issueId: string): Promise<WorkflowState>;
|
|
97
|
+
/**
|
|
98
|
+
* Append a failure reason to the accumulated failure summary.
|
|
99
|
+
* Format: --- Cycle {N}, {phase} Attempt {M} ({timestamp}) ---\n{reason}
|
|
100
|
+
*/
|
|
101
|
+
export declare function appendFailureSummary(issueId: string, newFailure: string): Promise<WorkflowState>;
|
|
102
|
+
/**
|
|
103
|
+
* Clear workflow state for an issue (called on acceptance pass)
|
|
104
|
+
*/
|
|
105
|
+
export declare function clearWorkflowState(issueId: string): Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* Extract a structured failure reason from an agent's result message.
|
|
108
|
+
* Strips boilerplate and keeps the substantive failure description.
|
|
109
|
+
*/
|
|
110
|
+
export declare function extractFailureReason(resultMessage: string | undefined): string;
|
|
31
111
|
/**
|
|
32
112
|
* Mark an issue as having been worked on by the agent
|
|
33
113
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent-tracking.d.ts","sourceRoot":"","sources":["../../src/agent-tracking.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;
|
|
1
|
+
{"version":3,"file":"agent-tracking.d.ts","sourceRoot":"","sources":["../../src/agent-tracking.ts"],"names":[],"mappings":"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"}
|
|
@@ -17,12 +17,130 @@ const QA_ATTEMPT_PREFIX = 'qa:attempt:';
|
|
|
17
17
|
const QA_FAILED_PREFIX = 'qa:failed:';
|
|
18
18
|
const DEV_QUEUED_PREFIX = 'agent:dev-queued:';
|
|
19
19
|
const ACCEPTANCE_QUEUED_PREFIX = 'agent:acceptance-queued:';
|
|
20
|
+
const WORKFLOW_STATE_PREFIX = 'workflow:state:';
|
|
20
21
|
// TTLs in seconds
|
|
21
22
|
const AGENT_WORKED_TTL = 7 * 24 * 60 * 60; // 7 days
|
|
22
23
|
const QA_ATTEMPT_TTL = 24 * 60 * 60; // 24 hours
|
|
23
24
|
const QA_FAILED_TTL = 60 * 60; // 1 hour
|
|
24
25
|
const DEV_QUEUED_TTL = 10; // 10 seconds - just enough to prevent duplicate webhooks
|
|
25
26
|
const ACCEPTANCE_QUEUED_TTL = 10; // 10 seconds - just enough to prevent duplicate webhooks
|
|
27
|
+
const WORKFLOW_STATE_TTL = 30 * 24 * 60 * 60; // 30 days
|
|
28
|
+
/**
|
|
29
|
+
* Compute escalation strategy deterministically from cycle count.
|
|
30
|
+
* Pure function — no side effects, no LLM.
|
|
31
|
+
*/
|
|
32
|
+
export function computeStrategy(cycleCount) {
|
|
33
|
+
if (cycleCount <= 1)
|
|
34
|
+
return 'normal';
|
|
35
|
+
if (cycleCount === 2)
|
|
36
|
+
return 'context-enriched';
|
|
37
|
+
if (cycleCount === 3)
|
|
38
|
+
return 'decompose';
|
|
39
|
+
return 'escalate-human';
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Get the workflow state for an issue
|
|
43
|
+
*/
|
|
44
|
+
export async function getWorkflowState(issueId) {
|
|
45
|
+
const key = `${WORKFLOW_STATE_PREFIX}${issueId}`;
|
|
46
|
+
return redisGet(key);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Update (partial merge) the workflow state for an issue.
|
|
50
|
+
* Creates the state if it doesn't exist.
|
|
51
|
+
*/
|
|
52
|
+
export async function updateWorkflowState(issueId, partial) {
|
|
53
|
+
const key = `${WORKFLOW_STATE_PREFIX}${issueId}`;
|
|
54
|
+
const existing = await redisGet(key);
|
|
55
|
+
const state = {
|
|
56
|
+
issueId,
|
|
57
|
+
issueIdentifier: partial.issueIdentifier ?? existing?.issueIdentifier ?? '',
|
|
58
|
+
cycleCount: partial.cycleCount ?? existing?.cycleCount ?? 0,
|
|
59
|
+
phases: {
|
|
60
|
+
development: partial.phases?.development ?? existing?.phases?.development ?? [],
|
|
61
|
+
qa: partial.phases?.qa ?? existing?.phases?.qa ?? [],
|
|
62
|
+
refinement: partial.phases?.refinement ?? existing?.phases?.refinement ?? [],
|
|
63
|
+
acceptance: partial.phases?.acceptance ?? existing?.phases?.acceptance ?? [],
|
|
64
|
+
},
|
|
65
|
+
strategy: partial.strategy ?? existing?.strategy ?? 'normal',
|
|
66
|
+
failureSummary: partial.failureSummary !== undefined ? partial.failureSummary : (existing?.failureSummary ?? null),
|
|
67
|
+
createdAt: existing?.createdAt ?? Date.now(),
|
|
68
|
+
updatedAt: Date.now(),
|
|
69
|
+
};
|
|
70
|
+
await redisSet(key, state, WORKFLOW_STATE_TTL);
|
|
71
|
+
return state;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Record a phase attempt in the workflow state
|
|
75
|
+
*/
|
|
76
|
+
export async function recordPhaseAttempt(issueId, phase, record) {
|
|
77
|
+
const existing = await getWorkflowState(issueId);
|
|
78
|
+
const phases = existing?.phases ?? { development: [], qa: [], refinement: [], acceptance: [] };
|
|
79
|
+
phases[phase] = [...phases[phase], record];
|
|
80
|
+
return updateWorkflowState(issueId, { phases });
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Increment the dev-QA-rejected cycle count and recompute strategy.
|
|
84
|
+
* Called when QA or acceptance fails and triggers a Rejected transition.
|
|
85
|
+
*/
|
|
86
|
+
export async function incrementCycleCount(issueId) {
|
|
87
|
+
const existing = await getWorkflowState(issueId);
|
|
88
|
+
const newCount = (existing?.cycleCount ?? 0) + 1;
|
|
89
|
+
const strategy = computeStrategy(newCount);
|
|
90
|
+
log.info('Workflow cycle incremented', { issueId, cycleCount: newCount, strategy });
|
|
91
|
+
return updateWorkflowState(issueId, { cycleCount: newCount, strategy });
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Append a failure reason to the accumulated failure summary.
|
|
95
|
+
* Format: --- Cycle {N}, {phase} Attempt {M} ({timestamp}) ---\n{reason}
|
|
96
|
+
*/
|
|
97
|
+
export async function appendFailureSummary(issueId, newFailure) {
|
|
98
|
+
const existing = await getWorkflowState(issueId);
|
|
99
|
+
const currentSummary = existing?.failureSummary ?? '';
|
|
100
|
+
const updatedSummary = currentSummary
|
|
101
|
+
? `${currentSummary}\n\n${newFailure}`
|
|
102
|
+
: newFailure;
|
|
103
|
+
return updateWorkflowState(issueId, { failureSummary: updatedSummary });
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Clear workflow state for an issue (called on acceptance pass)
|
|
107
|
+
*/
|
|
108
|
+
export async function clearWorkflowState(issueId) {
|
|
109
|
+
const key = `${WORKFLOW_STATE_PREFIX}${issueId}`;
|
|
110
|
+
await redisDel(key);
|
|
111
|
+
log.info('Cleared workflow state', { issueId });
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Extract a structured failure reason from an agent's result message.
|
|
115
|
+
* Strips boilerplate and keeps the substantive failure description.
|
|
116
|
+
*/
|
|
117
|
+
export function extractFailureReason(resultMessage) {
|
|
118
|
+
if (!resultMessage)
|
|
119
|
+
return 'No result message provided';
|
|
120
|
+
// Try to extract from structured sections
|
|
121
|
+
const failurePatterns = [
|
|
122
|
+
/##\s*(?:QA\s+)?(?:Failed|Failure|Issues?\s+Found)[^\n]*\n([\s\S]*?)(?=\n##|\n<!--|\Z)/i,
|
|
123
|
+
/(?:Fail(?:ure|ed)\s+(?:Reason|Details?|Summary)):?\s*([\s\S]*?)(?=\n##|\n<!--|\Z)/i,
|
|
124
|
+
/(?:Issues?\s+Found|Problems?\s+Found):?\s*([\s\S]*?)(?=\n##|\n<!--|\Z)/i,
|
|
125
|
+
];
|
|
126
|
+
for (const pattern of failurePatterns) {
|
|
127
|
+
const match = resultMessage.match(pattern);
|
|
128
|
+
if (match?.[1]) {
|
|
129
|
+
const extracted = match[1].trim();
|
|
130
|
+
if (extracted.length > 20) {
|
|
131
|
+
// Truncate if excessively long
|
|
132
|
+
return extracted.length > 2000 ? extracted.slice(0, 2000) + '...' : extracted;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Fallback: take the last meaningful paragraph (often the conclusion)
|
|
137
|
+
const paragraphs = resultMessage.split(/\n\n+/).filter(p => p.trim().length > 20);
|
|
138
|
+
if (paragraphs.length > 0) {
|
|
139
|
+
const last = paragraphs[paragraphs.length - 1].trim();
|
|
140
|
+
return last.length > 2000 ? last.slice(0, 2000) + '...' : last;
|
|
141
|
+
}
|
|
142
|
+
return resultMessage.slice(0, 500);
|
|
143
|
+
}
|
|
26
144
|
/**
|
|
27
145
|
* Mark an issue as having been worked on by the agent
|
|
28
146
|
*/
|
|
@@ -179,6 +297,7 @@ export async function cleanupAcceptedIssue(issueId) {
|
|
|
179
297
|
`${QA_FAILED_PREFIX}${issueId}`,
|
|
180
298
|
`${DEV_QUEUED_PREFIX}${issueId}`,
|
|
181
299
|
`${ACCEPTANCE_QUEUED_PREFIX}${issueId}`,
|
|
300
|
+
`${WORKFLOW_STATE_PREFIX}${issueId}`,
|
|
182
301
|
];
|
|
183
302
|
await Promise.all(keysToDelete.map((key) => redisDel(key)));
|
|
184
303
|
log.info('Cleaned up all tracking data for accepted issue', { issueId });
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Governor Storage — Redis Implementation
|
|
3
|
+
*
|
|
4
|
+
* Redis-backed storage adapter for the human touchpoint override state.
|
|
5
|
+
* Implements the OverrideStorage interface from @supaku/agentfactory (packages/core).
|
|
6
|
+
*/
|
|
7
|
+
import type { OverrideStorage, OverrideState } from '@supaku/agentfactory';
|
|
8
|
+
/**
|
|
9
|
+
* Redis-backed override storage for production use.
|
|
10
|
+
*
|
|
11
|
+
* Stores override state under `governor:override:{issueId}` keys
|
|
12
|
+
* with a 30-day TTL.
|
|
13
|
+
*/
|
|
14
|
+
export declare class RedisOverrideStorage implements OverrideStorage {
|
|
15
|
+
/**
|
|
16
|
+
* Get the override state for an issue from Redis
|
|
17
|
+
*/
|
|
18
|
+
get(issueId: string): Promise<OverrideState | null>;
|
|
19
|
+
/**
|
|
20
|
+
* Persist override state for an issue to Redis
|
|
21
|
+
*/
|
|
22
|
+
set(issueId: string, state: OverrideState): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* Remove override state for an issue from Redis
|
|
25
|
+
*/
|
|
26
|
+
clear(issueId: string): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=governor-storage.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"governor-storage.d.ts","sourceRoot":"","sources":["../../src/governor-storage.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAA;AAgB1E;;;;;GAKG;AACH,qBAAa,oBAAqB,YAAW,eAAe;IAC1D;;OAEG;IACG,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC;IAWzD;;OAEG;IACG,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IAM/D;;OAEG;IACG,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAK5C"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Governor Storage — Redis Implementation
|
|
3
|
+
*
|
|
4
|
+
* Redis-backed storage adapter for the human touchpoint override state.
|
|
5
|
+
* Implements the OverrideStorage interface from @supaku/agentfactory (packages/core).
|
|
6
|
+
*/
|
|
7
|
+
import { redisSet, redisGet, redisDel } from './redis.js';
|
|
8
|
+
const log = {
|
|
9
|
+
info: (msg, data) => console.log(`[governor-storage] ${msg}`, data ? JSON.stringify(data) : ''),
|
|
10
|
+
warn: (msg, data) => console.warn(`[governor-storage] ${msg}`, data ? JSON.stringify(data) : ''),
|
|
11
|
+
error: (msg, data) => console.error(`[governor-storage] ${msg}`, data ? JSON.stringify(data) : ''),
|
|
12
|
+
debug: (_msg, _data) => { },
|
|
13
|
+
};
|
|
14
|
+
/** Redis key prefix for governor override state */
|
|
15
|
+
const GOVERNOR_OVERRIDE_PREFIX = 'governor:override:';
|
|
16
|
+
/** TTL for override state: 30 days in seconds */
|
|
17
|
+
const GOVERNOR_OVERRIDE_TTL = 30 * 24 * 60 * 60;
|
|
18
|
+
/**
|
|
19
|
+
* Redis-backed override storage for production use.
|
|
20
|
+
*
|
|
21
|
+
* Stores override state under `governor:override:{issueId}` keys
|
|
22
|
+
* with a 30-day TTL.
|
|
23
|
+
*/
|
|
24
|
+
export class RedisOverrideStorage {
|
|
25
|
+
/**
|
|
26
|
+
* Get the override state for an issue from Redis
|
|
27
|
+
*/
|
|
28
|
+
async get(issueId) {
|
|
29
|
+
const key = `${GOVERNOR_OVERRIDE_PREFIX}${issueId}`;
|
|
30
|
+
const state = await redisGet(key);
|
|
31
|
+
if (state) {
|
|
32
|
+
log.debug('Retrieved override state', { issueId, type: state.directive.type });
|
|
33
|
+
}
|
|
34
|
+
return state;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Persist override state for an issue to Redis
|
|
38
|
+
*/
|
|
39
|
+
async set(issueId, state) {
|
|
40
|
+
const key = `${GOVERNOR_OVERRIDE_PREFIX}${issueId}`;
|
|
41
|
+
await redisSet(key, state, GOVERNOR_OVERRIDE_TTL);
|
|
42
|
+
log.info('Stored override state', { issueId, type: state.directive.type });
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Remove override state for an issue from Redis
|
|
46
|
+
*/
|
|
47
|
+
async clear(issueId) {
|
|
48
|
+
const key = `${GOVERNOR_OVERRIDE_PREFIX}${issueId}`;
|
|
49
|
+
await redisDel(key);
|
|
50
|
+
log.info('Cleared override state', { issueId });
|
|
51
|
+
}
|
|
52
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -14,4 +14,6 @@ export * from './session-hash.js';
|
|
|
14
14
|
export * from './rate-limit.js';
|
|
15
15
|
export * from './token-storage.js';
|
|
16
16
|
export * from './env-validation.js';
|
|
17
|
+
export * from './governor-storage.js';
|
|
18
|
+
export * from './processing-state-storage.js';
|
|
17
19
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/src/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AACA,cAAc,aAAa,CAAA;AAG3B,cAAc,YAAY,CAAA;AAG1B,cAAc,YAAY,CAAA;AAG1B,cAAc,sBAAsB,CAAA;AAGpC,cAAc,iBAAiB,CAAA;AAG/B,cAAc,qBAAqB,CAAA;AAGnC,cAAc,iBAAiB,CAAA;AAG/B,cAAc,qBAAqB,CAAA;AAGnC,cAAc,0BAA0B,CAAA;AAGxC,cAAc,sBAAsB,CAAA;AAGpC,cAAc,qBAAqB,CAAA;AAGnC,cAAc,kBAAkB,CAAA;AAGhC,cAAc,mBAAmB,CAAA;AAGjC,cAAc,iBAAiB,CAAA;AAG/B,cAAc,oBAAoB,CAAA;AAGlC,cAAc,qBAAqB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AACA,cAAc,aAAa,CAAA;AAG3B,cAAc,YAAY,CAAA;AAG1B,cAAc,YAAY,CAAA;AAG1B,cAAc,sBAAsB,CAAA;AAGpC,cAAc,iBAAiB,CAAA;AAG/B,cAAc,qBAAqB,CAAA;AAGnC,cAAc,iBAAiB,CAAA;AAG/B,cAAc,qBAAqB,CAAA;AAGnC,cAAc,0BAA0B,CAAA;AAGxC,cAAc,sBAAsB,CAAA;AAGpC,cAAc,qBAAqB,CAAA;AAGnC,cAAc,kBAAkB,CAAA;AAGhC,cAAc,mBAAmB,CAAA;AAGjC,cAAc,iBAAiB,CAAA;AAG/B,cAAc,oBAAoB,CAAA;AAGlC,cAAc,qBAAqB,CAAA;AAGnC,cAAc,uBAAuB,CAAA;AAGrC,cAAc,+BAA+B,CAAA"}
|
package/dist/src/index.js
CHANGED
|
@@ -30,3 +30,7 @@ export * from './rate-limit.js';
|
|
|
30
30
|
export * from './token-storage.js';
|
|
31
31
|
// Environment validation
|
|
32
32
|
export * from './env-validation.js';
|
|
33
|
+
// Governor storage (Redis-backed override state)
|
|
34
|
+
export * from './governor-storage.js';
|
|
35
|
+
// Processing state storage (Redis-backed top-of-funnel phase tracking)
|
|
36
|
+
export * from './processing-state-storage.js';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis-backed Processing State Storage
|
|
3
|
+
*
|
|
4
|
+
* Implements the `ProcessingStateStorage` interface from `@supaku/agentfactory`
|
|
5
|
+
* using Redis for persistence. Used by the top-of-funnel governor to track
|
|
6
|
+
* which processing phases (research, backlog-creation) have been completed
|
|
7
|
+
* for each issue.
|
|
8
|
+
*
|
|
9
|
+
* Key format: `governor:processing:{issueId}:{phase}`
|
|
10
|
+
* TTL: 30 days (matches workflow state TTL)
|
|
11
|
+
*/
|
|
12
|
+
import type { ProcessingStateStorage, ProcessingPhase, ProcessingRecord } from '@supaku/agentfactory';
|
|
13
|
+
/**
|
|
14
|
+
* Redis-backed implementation of `ProcessingStateStorage`.
|
|
15
|
+
*
|
|
16
|
+
* Each phase completion is stored as an independent key so that phases
|
|
17
|
+
* can be checked and cleared independently without affecting each other.
|
|
18
|
+
*/
|
|
19
|
+
export declare class RedisProcessingStateStorage implements ProcessingStateStorage {
|
|
20
|
+
/**
|
|
21
|
+
* Check whether a given phase has already been completed for an issue.
|
|
22
|
+
*/
|
|
23
|
+
isPhaseCompleted(issueId: string, phase: ProcessingPhase): Promise<boolean>;
|
|
24
|
+
/**
|
|
25
|
+
* Mark a phase as completed for an issue.
|
|
26
|
+
* Stores a `ProcessingRecord` JSON object with a 30-day TTL.
|
|
27
|
+
*/
|
|
28
|
+
markPhaseCompleted(issueId: string, phase: ProcessingPhase, sessionId?: string): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Clear a phase completion record for an issue.
|
|
31
|
+
*/
|
|
32
|
+
clearPhase(issueId: string, phase: ProcessingPhase): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Retrieve the processing record for a phase, if it exists.
|
|
35
|
+
*/
|
|
36
|
+
getPhaseRecord(issueId: string, phase: ProcessingPhase): Promise<ProcessingRecord | null>;
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=processing-state-storage.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"processing-state-storage.d.ts","sourceRoot":"","sources":["../../src/processing-state-storage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EACV,sBAAsB,EACtB,eAAe,EACf,gBAAgB,EACjB,MAAM,sBAAsB,CAAA;AAgB7B;;;;;GAKG;AACH,qBAAa,2BAA4B,YAAW,sBAAsB;IACxE;;OAEG;IACG,gBAAgB,CACpB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,eAAe,GACrB,OAAO,CAAC,OAAO,CAAC;IAInB;;;OAGG;IACG,kBAAkB,CACtB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,eAAe,EACtB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,IAAI,CAAC;IAUhB;;OAEG;IACG,UAAU,CACd,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,eAAe,GACrB,OAAO,CAAC,IAAI,CAAC;IAIhB;;OAEG;IACG,cAAc,CAClB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,eAAe,GACrB,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC;CAGpC"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis-backed Processing State Storage
|
|
3
|
+
*
|
|
4
|
+
* Implements the `ProcessingStateStorage` interface from `@supaku/agentfactory`
|
|
5
|
+
* using Redis for persistence. Used by the top-of-funnel governor to track
|
|
6
|
+
* which processing phases (research, backlog-creation) have been completed
|
|
7
|
+
* for each issue.
|
|
8
|
+
*
|
|
9
|
+
* Key format: `governor:processing:{issueId}:{phase}`
|
|
10
|
+
* TTL: 30 days (matches workflow state TTL)
|
|
11
|
+
*/
|
|
12
|
+
import { redisSet, redisGet, redisDel, redisExists } from './redis.js';
|
|
13
|
+
// Redis key prefix for processing state records
|
|
14
|
+
const PROCESSING_STATE_PREFIX = 'governor:processing:';
|
|
15
|
+
// 30-day TTL in seconds
|
|
16
|
+
const PROCESSING_STATE_TTL = 30 * 24 * 60 * 60;
|
|
17
|
+
/**
|
|
18
|
+
* Build the Redis key for a specific issue + phase combination.
|
|
19
|
+
*/
|
|
20
|
+
function redisKey(issueId, phase) {
|
|
21
|
+
return `${PROCESSING_STATE_PREFIX}${issueId}:${phase}`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Redis-backed implementation of `ProcessingStateStorage`.
|
|
25
|
+
*
|
|
26
|
+
* Each phase completion is stored as an independent key so that phases
|
|
27
|
+
* can be checked and cleared independently without affecting each other.
|
|
28
|
+
*/
|
|
29
|
+
export class RedisProcessingStateStorage {
|
|
30
|
+
/**
|
|
31
|
+
* Check whether a given phase has already been completed for an issue.
|
|
32
|
+
*/
|
|
33
|
+
async isPhaseCompleted(issueId, phase) {
|
|
34
|
+
return redisExists(redisKey(issueId, phase));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Mark a phase as completed for an issue.
|
|
38
|
+
* Stores a `ProcessingRecord` JSON object with a 30-day TTL.
|
|
39
|
+
*/
|
|
40
|
+
async markPhaseCompleted(issueId, phase, sessionId) {
|
|
41
|
+
const record = {
|
|
42
|
+
issueId,
|
|
43
|
+
phase,
|
|
44
|
+
completedAt: Date.now(),
|
|
45
|
+
sessionId,
|
|
46
|
+
};
|
|
47
|
+
await redisSet(redisKey(issueId, phase), record, PROCESSING_STATE_TTL);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Clear a phase completion record for an issue.
|
|
51
|
+
*/
|
|
52
|
+
async clearPhase(issueId, phase) {
|
|
53
|
+
await redisDel(redisKey(issueId, phase));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Retrieve the processing record for a phase, if it exists.
|
|
57
|
+
*/
|
|
58
|
+
async getPhaseRecord(issueId, phase) {
|
|
59
|
+
return redisGet(redisKey(issueId, phase));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workflow-state-integration.test.d.ts","sourceRoot":"","sources":["../../src/workflow-state-integration.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the 4-cycle workflow state progression
|
|
3
|
+
* and escalate-human circuit breaker.
|
|
4
|
+
*
|
|
5
|
+
* Mocks Redis to test the full lifecycle:
|
|
6
|
+
* Cycle 1 (normal) → Cycle 2 (context-enriched) → Cycle 3 (decompose) → Cycle 4 (escalate-human)
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
9
|
+
// In-memory Redis store for testing
|
|
10
|
+
const store = new Map();
|
|
11
|
+
vi.mock('./redis.js', () => ({
|
|
12
|
+
redisSet: vi.fn(async (key, value, _ttl) => {
|
|
13
|
+
store.set(key, JSON.stringify(value));
|
|
14
|
+
}),
|
|
15
|
+
redisGet: vi.fn(async (key) => {
|
|
16
|
+
const val = store.get(key);
|
|
17
|
+
return val ? JSON.parse(val) : null;
|
|
18
|
+
}),
|
|
19
|
+
redisDel: vi.fn(async (key) => {
|
|
20
|
+
const existed = store.has(key) ? 1 : 0;
|
|
21
|
+
store.delete(key);
|
|
22
|
+
return existed;
|
|
23
|
+
}),
|
|
24
|
+
redisExists: vi.fn(async (key) => store.has(key)),
|
|
25
|
+
}));
|
|
26
|
+
import { computeStrategy, getWorkflowState, updateWorkflowState, recordPhaseAttempt, incrementCycleCount, appendFailureSummary, clearWorkflowState, extractFailureReason, } from './agent-tracking.js';
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
store.clear();
|
|
29
|
+
});
|
|
30
|
+
/**
|
|
31
|
+
* Simulate what the webhook-orchestrator onAgentComplete handler does
|
|
32
|
+
* when a QA/acceptance agent fails.
|
|
33
|
+
*/
|
|
34
|
+
async function simulateQAFailure(issueId, failureMessage) {
|
|
35
|
+
const state = await incrementCycleCount(issueId);
|
|
36
|
+
const failureReason = extractFailureReason(failureMessage);
|
|
37
|
+
const formattedFailure = `--- Cycle ${state.cycleCount}, qa (${new Date().toISOString()}) ---\n${failureReason}`;
|
|
38
|
+
await appendFailureSummary(issueId, formattedFailure);
|
|
39
|
+
return await getWorkflowState(issueId);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Simulate what the webhook-orchestrator onAgentComplete handler does
|
|
43
|
+
* when recording a phase attempt.
|
|
44
|
+
*/
|
|
45
|
+
async function simulatePhaseCompletion(issueId, phase, result, costUsd = 0.50) {
|
|
46
|
+
await recordPhaseAttempt(issueId, phase, {
|
|
47
|
+
attempt: 1,
|
|
48
|
+
sessionId: `session-${phase}-${Date.now()}`,
|
|
49
|
+
startedAt: Date.now(),
|
|
50
|
+
completedAt: Date.now(),
|
|
51
|
+
result,
|
|
52
|
+
costUsd,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
describe('4-cycle workflow progression integration', () => {
|
|
56
|
+
const issueId = 'test-issue-001';
|
|
57
|
+
const issueIdentifier = 'TEST-42';
|
|
58
|
+
it('progresses through all 4 escalation tiers across full dev-QA-rejected cycles', async () => {
|
|
59
|
+
// Initialize workflow state (happens on first agent completion)
|
|
60
|
+
await updateWorkflowState(issueId, { issueIdentifier });
|
|
61
|
+
// === Cycle 1: normal ===
|
|
62
|
+
// Development completes, QA fails
|
|
63
|
+
await simulatePhaseCompletion(issueId, 'development', 'passed');
|
|
64
|
+
await simulatePhaseCompletion(issueId, 'qa', 'failed');
|
|
65
|
+
let state = await simulateQAFailure(issueId, '## QA Failed\n\nTests failed: TypeError in handleUserLogin — null check missing');
|
|
66
|
+
expect(state).not.toBeNull();
|
|
67
|
+
expect(state.cycleCount).toBe(1);
|
|
68
|
+
expect(state.strategy).toBe('normal');
|
|
69
|
+
expect(state.failureSummary).toContain('TypeError in handleUserLogin');
|
|
70
|
+
// At cycle 1 (normal), strategy would provide retry context for development
|
|
71
|
+
expect(computeStrategy(1)).toBe('normal');
|
|
72
|
+
// === Cycle 2: context-enriched ===
|
|
73
|
+
// Refinement completes, development re-runs, QA fails again
|
|
74
|
+
await simulatePhaseCompletion(issueId, 'refinement', 'passed');
|
|
75
|
+
await simulatePhaseCompletion(issueId, 'development', 'passed');
|
|
76
|
+
await simulatePhaseCompletion(issueId, 'qa', 'failed');
|
|
77
|
+
state = await simulateQAFailure(issueId, '## QA Failed\n\nBuild passes but integration test for /api/users returns 500. Missing database migration.');
|
|
78
|
+
expect(state.cycleCount).toBe(2);
|
|
79
|
+
expect(state.strategy).toBe('context-enriched');
|
|
80
|
+
expect(state.failureSummary).toContain('Cycle 1');
|
|
81
|
+
expect(state.failureSummary).toContain('Cycle 2');
|
|
82
|
+
expect(state.failureSummary).toContain('database migration');
|
|
83
|
+
// At cycle 2 (context-enriched), refinement gets full failure history
|
|
84
|
+
expect(computeStrategy(2)).toBe('context-enriched');
|
|
85
|
+
// === Cycle 3: decompose ===
|
|
86
|
+
await simulatePhaseCompletion(issueId, 'refinement', 'passed');
|
|
87
|
+
await simulatePhaseCompletion(issueId, 'development', 'passed');
|
|
88
|
+
await simulatePhaseCompletion(issueId, 'qa', 'failed');
|
|
89
|
+
state = await simulateQAFailure(issueId, 'Failure Reason: The same /api/users endpoint still returns 500. Root cause is the missing foreign key constraint in the migration.');
|
|
90
|
+
expect(state.cycleCount).toBe(3);
|
|
91
|
+
expect(state.strategy).toBe('decompose');
|
|
92
|
+
expect(state.failureSummary).toContain('Cycle 3');
|
|
93
|
+
// At cycle 3 (decompose), refinement gets decomposition instructions
|
|
94
|
+
expect(computeStrategy(3)).toBe('decompose');
|
|
95
|
+
// === Cycle 4: escalate-human ===
|
|
96
|
+
await simulatePhaseCompletion(issueId, 'refinement', 'passed');
|
|
97
|
+
await simulatePhaseCompletion(issueId, 'development', 'passed');
|
|
98
|
+
await simulatePhaseCompletion(issueId, 'qa', 'failed');
|
|
99
|
+
state = await simulateQAFailure(issueId, 'Issues Found:\n1. API endpoint still broken\n2. Migration file has syntax error\n3. Schema mismatch between ORM and SQL');
|
|
100
|
+
expect(state.cycleCount).toBe(4);
|
|
101
|
+
expect(state.strategy).toBe('escalate-human');
|
|
102
|
+
expect(state.failureSummary).toContain('Cycle 4');
|
|
103
|
+
// At cycle 4+ (escalate-human), the loop should stop entirely
|
|
104
|
+
expect(computeStrategy(state.cycleCount)).toBe('escalate-human');
|
|
105
|
+
expect(computeStrategy(state.cycleCount + 1)).toBe('escalate-human');
|
|
106
|
+
// Verify all 4 phase records tracked for development and QA
|
|
107
|
+
expect(state.phases.development).toHaveLength(4);
|
|
108
|
+
expect(state.phases.qa).toHaveLength(4);
|
|
109
|
+
expect(state.phases.refinement).toHaveLength(3);
|
|
110
|
+
});
|
|
111
|
+
it('accumulates failure summaries across all cycles with correct formatting', async () => {
|
|
112
|
+
await updateWorkflowState(issueId, { issueIdentifier });
|
|
113
|
+
// Simulate 4 failures with distinct messages
|
|
114
|
+
const failures = [
|
|
115
|
+
'## QA Failed\n\nNull pointer in UserService.getById',
|
|
116
|
+
'Failure Reason: Missing validation on email field causes 500',
|
|
117
|
+
'Issues Found:\n1. Race condition in session manager\n2. Stale cache returns wrong user',
|
|
118
|
+
'## QA Failed\n\nDatabase connection pool exhausted after 10 concurrent requests',
|
|
119
|
+
];
|
|
120
|
+
for (const msg of failures) {
|
|
121
|
+
await simulateQAFailure(issueId, msg);
|
|
122
|
+
}
|
|
123
|
+
const state = await getWorkflowState(issueId);
|
|
124
|
+
expect(state).not.toBeNull();
|
|
125
|
+
expect(state.cycleCount).toBe(4);
|
|
126
|
+
expect(state.strategy).toBe('escalate-human');
|
|
127
|
+
// All 4 cycle markers should be present
|
|
128
|
+
expect(state.failureSummary).toContain('Cycle 1');
|
|
129
|
+
expect(state.failureSummary).toContain('Cycle 2');
|
|
130
|
+
expect(state.failureSummary).toContain('Cycle 3');
|
|
131
|
+
expect(state.failureSummary).toContain('Cycle 4');
|
|
132
|
+
// Failure details from each cycle should be present
|
|
133
|
+
expect(state.failureSummary).toContain('Null pointer in UserService');
|
|
134
|
+
expect(state.failureSummary).toContain('Missing validation on email');
|
|
135
|
+
expect(state.failureSummary).toContain('Race condition in session manager');
|
|
136
|
+
expect(state.failureSummary).toContain('Database connection pool exhausted');
|
|
137
|
+
});
|
|
138
|
+
it('tracks per-phase attempt records and costs across cycles', async () => {
|
|
139
|
+
await updateWorkflowState(issueId, { issueIdentifier });
|
|
140
|
+
// Cycle 1: dev → qa(fail)
|
|
141
|
+
await simulatePhaseCompletion(issueId, 'development', 'passed', 1.50);
|
|
142
|
+
await simulatePhaseCompletion(issueId, 'qa', 'failed', 0.75);
|
|
143
|
+
await simulateQAFailure(issueId, 'Tests failed');
|
|
144
|
+
// Cycle 2: refinement → dev → qa(fail)
|
|
145
|
+
await simulatePhaseCompletion(issueId, 'refinement', 'passed', 0.50);
|
|
146
|
+
await simulatePhaseCompletion(issueId, 'development', 'passed', 2.00);
|
|
147
|
+
await simulatePhaseCompletion(issueId, 'qa', 'failed', 0.80);
|
|
148
|
+
await simulateQAFailure(issueId, 'Build failed');
|
|
149
|
+
const state = await getWorkflowState(issueId);
|
|
150
|
+
expect(state).not.toBeNull();
|
|
151
|
+
// Check phase records exist
|
|
152
|
+
expect(state.phases.development).toHaveLength(2);
|
|
153
|
+
expect(state.phases.qa).toHaveLength(2);
|
|
154
|
+
expect(state.phases.refinement).toHaveLength(1);
|
|
155
|
+
expect(state.phases.acceptance).toHaveLength(0);
|
|
156
|
+
// Calculate total cost (simulates what the escalation comment builder does)
|
|
157
|
+
const allPhases = [
|
|
158
|
+
...state.phases.development,
|
|
159
|
+
...state.phases.qa,
|
|
160
|
+
...state.phases.refinement,
|
|
161
|
+
...state.phases.acceptance,
|
|
162
|
+
];
|
|
163
|
+
const totalCost = allPhases.reduce((sum, p) => sum + (p.costUsd ?? 0), 0);
|
|
164
|
+
expect(totalCost).toBeCloseTo(5.55, 2);
|
|
165
|
+
});
|
|
166
|
+
it('clears workflow state on acceptance pass', async () => {
|
|
167
|
+
await updateWorkflowState(issueId, { issueIdentifier });
|
|
168
|
+
await simulateQAFailure(issueId, 'Tests failed');
|
|
169
|
+
let state = await getWorkflowState(issueId);
|
|
170
|
+
expect(state).not.toBeNull();
|
|
171
|
+
expect(state.cycleCount).toBe(1);
|
|
172
|
+
// Acceptance pass clears everything
|
|
173
|
+
await clearWorkflowState(issueId);
|
|
174
|
+
state = await getWorkflowState(issueId);
|
|
175
|
+
expect(state).toBeNull();
|
|
176
|
+
});
|
|
177
|
+
it('strategy-based circuit breaker blocks at cycle 4+', async () => {
|
|
178
|
+
await updateWorkflowState(issueId, { issueIdentifier });
|
|
179
|
+
// Progress to escalate-human (cycle 4)
|
|
180
|
+
for (let i = 0; i < 4; i++) {
|
|
181
|
+
await simulateQAFailure(issueId, `Failure in cycle ${i + 1}`);
|
|
182
|
+
}
|
|
183
|
+
const state = await getWorkflowState(issueId);
|
|
184
|
+
expect(state.strategy).toBe('escalate-human');
|
|
185
|
+
// Circuit breaker check: this is what issue-updated.ts does
|
|
186
|
+
const shouldBlock = state.strategy === 'escalate-human';
|
|
187
|
+
expect(shouldBlock).toBe(true);
|
|
188
|
+
// Verify further increments stay at escalate-human
|
|
189
|
+
await simulateQAFailure(issueId, 'Yet another failure');
|
|
190
|
+
const state2 = await getWorkflowState(issueId);
|
|
191
|
+
expect(state2.cycleCount).toBe(5);
|
|
192
|
+
expect(state2.strategy).toBe('escalate-human');
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
describe('escalate-human blocker creation data', () => {
|
|
196
|
+
const issueId = 'blocker-test-001';
|
|
197
|
+
const issueIdentifier = 'TEST-99';
|
|
198
|
+
it('provides correct data for blocker issue creation at cycle 4', async () => {
|
|
199
|
+
await updateWorkflowState(issueId, { issueIdentifier });
|
|
200
|
+
// Simulate 4 cycles with realistic failures
|
|
201
|
+
const qaFailures = [
|
|
202
|
+
'## QA Failed\n\nUnit tests in packages/core fail with TypeError: Cannot read property of undefined',
|
|
203
|
+
'Failure Reason: Integration test for webhook handler times out after 30s',
|
|
204
|
+
'Issues Found:\n1. Missing mock for Redis in test setup\n2. Race condition in async handler',
|
|
205
|
+
'## QA Failed\n\nTypecheck fails: Property "strategy" does not exist on type "WorkflowState"',
|
|
206
|
+
];
|
|
207
|
+
for (const msg of qaFailures) {
|
|
208
|
+
await simulatePhaseCompletion(issueId, 'development', 'passed', 1.00);
|
|
209
|
+
await simulatePhaseCompletion(issueId, 'qa', 'failed', 0.50);
|
|
210
|
+
await simulateQAFailure(issueId, msg);
|
|
211
|
+
}
|
|
212
|
+
const state = await getWorkflowState(issueId);
|
|
213
|
+
expect(state).not.toBeNull();
|
|
214
|
+
expect(state.strategy).toBe('escalate-human');
|
|
215
|
+
expect(state.cycleCount).toBe(4);
|
|
216
|
+
// Verify the data that would be used to create the blocker issue
|
|
217
|
+
const { cycleCount, failureSummary, phases } = state;
|
|
218
|
+
// Blocker title matches what issue-updated.ts generates
|
|
219
|
+
const blockerTitle = `Human review needed: ${issueIdentifier} failed ${cycleCount} automated cycles`;
|
|
220
|
+
expect(blockerTitle).toBe('Human review needed: TEST-99 failed 4 automated cycles');
|
|
221
|
+
// Blocker description includes failure summary
|
|
222
|
+
const blockerDescription = [
|
|
223
|
+
`This issue has failed **${cycleCount} automated dev-QA-rejected cycles** and requires human intervention.`,
|
|
224
|
+
'',
|
|
225
|
+
'### Failure History',
|
|
226
|
+
failureSummary ?? 'No failure details recorded.',
|
|
227
|
+
'',
|
|
228
|
+
'---',
|
|
229
|
+
`*Source issue: ${issueIdentifier}*`,
|
|
230
|
+
].join('\n');
|
|
231
|
+
expect(blockerDescription).toContain('4 automated dev-QA-rejected cycles');
|
|
232
|
+
expect(blockerDescription).toContain('TypeError: Cannot read property');
|
|
233
|
+
expect(blockerDescription).toContain('Race condition in async handler');
|
|
234
|
+
expect(blockerDescription).toContain('Source issue: TEST-99');
|
|
235
|
+
// Escalation comment includes total cost
|
|
236
|
+
const allPhases = [
|
|
237
|
+
...phases.development,
|
|
238
|
+
...phases.qa,
|
|
239
|
+
...phases.refinement,
|
|
240
|
+
...phases.acceptance,
|
|
241
|
+
];
|
|
242
|
+
const totalCost = allPhases.reduce((sum, p) => sum + (p.costUsd ?? 0), 0);
|
|
243
|
+
expect(totalCost).toBeCloseTo(6.00, 2); // 4 × (1.00 + 0.50)
|
|
244
|
+
const costLine = totalCost > 0 ? `\n**Total cost across all attempts:** $${totalCost.toFixed(2)}` : '';
|
|
245
|
+
expect(costLine).toContain('$6.00');
|
|
246
|
+
// Escalation comment content
|
|
247
|
+
const escalationComment = `## Circuit Breaker: Human Intervention Required\n\n` +
|
|
248
|
+
`This issue has gone through **${cycleCount} dev-QA-rejected cycles** without passing.\n` +
|
|
249
|
+
`The automated system is stopping further attempts.\n` +
|
|
250
|
+
costLine +
|
|
251
|
+
`\n\n### Failure History\n\n${failureSummary ?? 'No failure details recorded.'}\n\n` +
|
|
252
|
+
`### Recommended Actions\n` +
|
|
253
|
+
`1. Review the failure patterns above\n` +
|
|
254
|
+
`2. Consider if the acceptance criteria need clarification\n` +
|
|
255
|
+
`3. Investigate whether there's an architectural issue\n` +
|
|
256
|
+
`4. Manually fix or decompose the issue before re-enabling automation`;
|
|
257
|
+
expect(escalationComment).toContain('4 dev-QA-rejected cycles');
|
|
258
|
+
expect(escalationComment).toContain('$6.00');
|
|
259
|
+
expect(escalationComment).toContain('Cycle 1');
|
|
260
|
+
expect(escalationComment).toContain('Cycle 4');
|
|
261
|
+
expect(escalationComment).toContain('Recommended Actions');
|
|
262
|
+
});
|
|
263
|
+
it('verifies escalation strategy is deterministic and never regresses', async () => {
|
|
264
|
+
await updateWorkflowState(issueId, { issueIdentifier });
|
|
265
|
+
const expectedProgression = [
|
|
266
|
+
{ cycle: 1, strategy: 'normal' },
|
|
267
|
+
{ cycle: 2, strategy: 'context-enriched' },
|
|
268
|
+
{ cycle: 3, strategy: 'decompose' },
|
|
269
|
+
{ cycle: 4, strategy: 'escalate-human' },
|
|
270
|
+
{ cycle: 5, strategy: 'escalate-human' },
|
|
271
|
+
{ cycle: 6, strategy: 'escalate-human' },
|
|
272
|
+
];
|
|
273
|
+
for (const { cycle, strategy } of expectedProgression) {
|
|
274
|
+
const state = await incrementCycleCount(issueId);
|
|
275
|
+
expect(state.cycleCount).toBe(cycle);
|
|
276
|
+
expect(state.strategy).toBe(strategy);
|
|
277
|
+
// Verify computeStrategy matches what's stored
|
|
278
|
+
expect(computeStrategy(state.cycleCount)).toBe(state.strategy);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workflow-state.test.d.ts","sourceRoot":"","sources":["../../src/workflow-state.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { computeStrategy, extractFailureReason } from './agent-tracking.js';
|
|
3
|
+
describe('computeStrategy', () => {
|
|
4
|
+
it('returns normal for cycle 0', () => {
|
|
5
|
+
expect(computeStrategy(0)).toBe('normal');
|
|
6
|
+
});
|
|
7
|
+
it('returns normal for cycle 1', () => {
|
|
8
|
+
expect(computeStrategy(1)).toBe('normal');
|
|
9
|
+
});
|
|
10
|
+
it('returns context-enriched for cycle 2', () => {
|
|
11
|
+
expect(computeStrategy(2)).toBe('context-enriched');
|
|
12
|
+
});
|
|
13
|
+
it('returns decompose for cycle 3', () => {
|
|
14
|
+
expect(computeStrategy(3)).toBe('decompose');
|
|
15
|
+
});
|
|
16
|
+
it('returns escalate-human for cycle 4', () => {
|
|
17
|
+
expect(computeStrategy(4)).toBe('escalate-human');
|
|
18
|
+
});
|
|
19
|
+
it('returns escalate-human for cycle 5+', () => {
|
|
20
|
+
expect(computeStrategy(5)).toBe('escalate-human');
|
|
21
|
+
expect(computeStrategy(10)).toBe('escalate-human');
|
|
22
|
+
expect(computeStrategy(100)).toBe('escalate-human');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe('extractFailureReason', () => {
|
|
26
|
+
it('returns default message for undefined input', () => {
|
|
27
|
+
expect(extractFailureReason(undefined)).toBe('No result message provided');
|
|
28
|
+
});
|
|
29
|
+
it('extracts from ## QA Failed section', () => {
|
|
30
|
+
const msg = `## QA Failed
|
|
31
|
+
|
|
32
|
+
The build is broken because the new function does not handle null inputs correctly.
|
|
33
|
+
Tests in packages/core are failing with TypeError.
|
|
34
|
+
|
|
35
|
+
<!-- WORK_RESULT:failed -->`;
|
|
36
|
+
const result = extractFailureReason(msg);
|
|
37
|
+
expect(result).toContain('build is broken');
|
|
38
|
+
expect(result).toContain('null inputs');
|
|
39
|
+
});
|
|
40
|
+
it('extracts from Failure Reason: pattern', () => {
|
|
41
|
+
const msg = `QA Status: Failed
|
|
42
|
+
|
|
43
|
+
Failure Reason: The API endpoint returns 500 when called with an empty payload. The validation middleware is not catching the missing required field.`;
|
|
44
|
+
const result = extractFailureReason(msg);
|
|
45
|
+
expect(result).toContain('API endpoint returns 500');
|
|
46
|
+
});
|
|
47
|
+
it('extracts from Issues Found pattern', () => {
|
|
48
|
+
const msg = `## QA Report
|
|
49
|
+
|
|
50
|
+
Issues Found:
|
|
51
|
+
1. Missing error handling in the login flow
|
|
52
|
+
2. CSS layout broken on mobile viewport
|
|
53
|
+
3. Unit test for UserService is failing`;
|
|
54
|
+
const result = extractFailureReason(msg);
|
|
55
|
+
expect(result).toContain('Missing error handling');
|
|
56
|
+
});
|
|
57
|
+
it('falls back to last paragraph for unstructured messages', () => {
|
|
58
|
+
const msg = `I looked at the code and ran the tests.
|
|
59
|
+
|
|
60
|
+
Some things worked but others did not.
|
|
61
|
+
|
|
62
|
+
The main issue is that the database migration was not applied correctly and the new column is missing from the users table, causing all queries to fail with a column not found error.`;
|
|
63
|
+
const result = extractFailureReason(msg);
|
|
64
|
+
expect(result).toContain('database migration');
|
|
65
|
+
});
|
|
66
|
+
it('truncates very long failure reasons', () => {
|
|
67
|
+
const longReason = 'x'.repeat(3000);
|
|
68
|
+
const msg = `## QA Failed\n${longReason}`;
|
|
69
|
+
const result = extractFailureReason(msg);
|
|
70
|
+
expect(result.length).toBeLessThanOrEqual(2003); // 2000 + '...'
|
|
71
|
+
expect(result.endsWith('...')).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
it('handles short result messages gracefully', () => {
|
|
74
|
+
const msg = 'Failed';
|
|
75
|
+
const result = extractFailureReason(msg);
|
|
76
|
+
expect(result).toBe('Failed');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
describe('escalation ladder progression', () => {
|
|
80
|
+
it('follows the correct strategy progression through 5 cycles', () => {
|
|
81
|
+
const expected = [
|
|
82
|
+
'normal', // cycle 0 (first attempt)
|
|
83
|
+
'normal', // cycle 1
|
|
84
|
+
'context-enriched', // cycle 2
|
|
85
|
+
'decompose', // cycle 3
|
|
86
|
+
'escalate-human', // cycle 4
|
|
87
|
+
];
|
|
88
|
+
for (let cycle = 0; cycle < expected.length; cycle++) {
|
|
89
|
+
expect(computeStrategy(cycle)).toBe(expected[cycle]);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
it('never returns a strategy weaker than escalate-human once reached', () => {
|
|
93
|
+
// Once at escalate-human, it stays there
|
|
94
|
+
for (let cycle = 4; cycle <= 20; cycle++) {
|
|
95
|
+
expect(computeStrategy(cycle)).toBe('escalate-human');
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
it('strategy always increases or stays at ceiling', () => {
|
|
99
|
+
const strategyOrder = {
|
|
100
|
+
'normal': 0,
|
|
101
|
+
'context-enriched': 1,
|
|
102
|
+
'decompose': 2,
|
|
103
|
+
'escalate-human': 3,
|
|
104
|
+
};
|
|
105
|
+
let previousLevel = -1;
|
|
106
|
+
for (let cycle = 0; cycle <= 10; cycle++) {
|
|
107
|
+
const strategy = computeStrategy(cycle);
|
|
108
|
+
const level = strategyOrder[strategy];
|
|
109
|
+
expect(level).toBeGreaterThanOrEqual(previousLevel);
|
|
110
|
+
previousLevel = level;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supaku/agentfactory-server",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Webhook server and distributed worker pool for AgentFactory — Redis queues, issue locks, session management",
|
|
6
6
|
"author": "Supaku (https://supaku.com)",
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
],
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"ioredis": "^5.4.2",
|
|
47
|
-
"@supaku/agentfactory": "0.7.
|
|
48
|
-
"@supaku/agentfactory
|
|
47
|
+
"@supaku/agentfactory-linear": "0.7.10",
|
|
48
|
+
"@supaku/agentfactory": "0.7.10"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@types/node": "^22.5.4",
|