principles-disciple 1.53.0 → 1.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.planning/phases/01-basic-visualization/01-GAP-CLOSURE-VERIFICATION.md +113 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/core/bootstrap-rules.ts +3 -1
- package/src/core/pain-context-extractor.ts +12 -7
- package/src/core/pain-lifecycle.ts +38 -0
- package/src/core/pain-signal.ts +6 -3
- package/src/service/event-log-auditor.ts +10 -2
- package/src/service/evolution-worker.ts +10 -3
- package/tests/core/pain-lifecycle.test.ts +37 -0
- package/tests/core/pain-signal.test.ts +2 -2
- package/tests/integration/pain-lifecycle-e2e.test.ts +74 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
---
|
|
2
|
+
phase: "01-basic-visualization"
|
|
3
|
+
plan: "01-GAP-CLOSURE"
|
|
4
|
+
verified: 2026-04-17T15:30:00Z
|
|
5
|
+
status: passed
|
|
6
|
+
score: "4/4 must-haves verified"
|
|
7
|
+
overrides_applied: 0
|
|
8
|
+
re_verification: false
|
|
9
|
+
gaps: []
|
|
10
|
+
deferred: []
|
|
11
|
+
human_verification: []
|
|
12
|
+
requirement_mismatch:
|
|
13
|
+
note: "Requirement IDs provided (SDK-CORE-03, SDK-ADP-07, SDK-ADP-08, SDK-TEST-02, SDK-TEST-03, SDK-MGMT-01, SDK-MGMT-02) are SDK Core Implementation Phase 1 requirements per REQUIREMENTS.md. This phase (01-basic-visualization) addresses VIZ-04 (Empty state optimization) - a different domain. Requirement coverage cannot be established for IDs that do not belong to this phase."
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# Phase 01-basic-visualization: GAP-CLOSURE Verification Report
|
|
17
|
+
|
|
18
|
+
**Phase Goal:** Close verification gaps from Phase 01 by implementing missing i18n fixes for LineChart component and coverage trend empty state.
|
|
19
|
+
**Verified:** 2026-04-17T15:30:00Z
|
|
20
|
+
**Status:** passed
|
|
21
|
+
**Re-verification:** No — initial verification
|
|
22
|
+
|
|
23
|
+
## Goal Achievement
|
|
24
|
+
|
|
25
|
+
### Observable Truths
|
|
26
|
+
|
|
27
|
+
| # | Truth | Status | Evidence |
|
|
28
|
+
| --- | ----- | ------ | -------- |
|
|
29
|
+
| 1 | LineChart interface has emptyText prop for i18n | VERIFIED | `emptyText?: string;` at line 864 in charts.tsx |
|
|
30
|
+
| 2 | LineChart renders emptyText prop instead of hardcoded '暂无数据' | VERIFIED | Conditional render at lines 878-885: `if (!emptyText) return null;` then `{emptyText}` |
|
|
31
|
+
| 3 | Coverage trend section shows EmptyState when no data | VERIFIED | Ternary at line 259: `data.coverageTrend.length >= 1 ? (...) : (<EmptyState ...>)` |
|
|
32
|
+
| 4 | All LineChart usages pass emptyText prop | VERIFIED | Lines 272, 454, 521 all have `emptyText={t('common.noData')}` |
|
|
33
|
+
|
|
34
|
+
**Score:** 4/4 truths verified
|
|
35
|
+
|
|
36
|
+
### Deferred Items
|
|
37
|
+
|
|
38
|
+
None
|
|
39
|
+
|
|
40
|
+
### Required Artifacts
|
|
41
|
+
|
|
42
|
+
| Artifact | Expected | Status | Details |
|
|
43
|
+
| -------- | -------- | ------ | ------- |
|
|
44
|
+
| `charts.tsx:864` | `emptyText?: string` in LineChartProps | VERIFIED | Prop exists at correct line |
|
|
45
|
+
| `charts.tsx:876` | Default value `emptyText = ''` | VERIFIED | Default empty string assigned |
|
|
46
|
+
| `charts.tsx:878-885` | Conditional render using emptyText | VERIFIED | Returns null if no emptyText, renders div with {emptyText} otherwise |
|
|
47
|
+
| `ThinkingModelsPage.tsx:259` | Ternary operator for coverage trend | VERIFIED | `data.coverageTrend.length >= 1 ? (...) : (<EmptyState ...>)` |
|
|
48
|
+
| `ThinkingModelsPage.tsx:272` | LineChart with emptyText prop | VERIFIED | `emptyText={t('common.noData')}` |
|
|
49
|
+
| `ThinkingModelsPage.tsx:454` | LineChart with emptyText prop | VERIFIED | `emptyText={t('common.noData')}` |
|
|
50
|
+
| `ThinkingModelsPage.tsx:521` | LineChart with emptyText prop | VERIFIED | `emptyText={t('common.noData')}` |
|
|
51
|
+
|
|
52
|
+
### Key Link Verification
|
|
53
|
+
|
|
54
|
+
| From | To | Via | Status | Details |
|
|
55
|
+
| ---- | --- | --- | ------ | ------- |
|
|
56
|
+
| LineChart | i18n system | emptyText prop | WIRED | emptyText prop accepts i18n string and renders it |
|
|
57
|
+
| Coverage trend | EmptyState | ternary operator | WIRED | Ternary correctly switches between LineChart and EmptyState |
|
|
58
|
+
| ThinkingModelsPage | common.noData | t() function | WIRED | All LineChart usages pass i18n key |
|
|
59
|
+
| EmptyState | i18n keys | t() function | WIRED | `t('thinkingModels.emptyCoverageTrend')` and `t('thinkingModels.emptyCoverageTrendDesc')` used |
|
|
60
|
+
|
|
61
|
+
### Data-Flow Trace (Level 4)
|
|
62
|
+
|
|
63
|
+
| Artifact | Data Variable | Source | Produces Real Data | Status |
|
|
64
|
+
| -------- | ------------- | ------ | ------------------ | ------ |
|
|
65
|
+
| LineChart emptyText | emptyText prop | Parent component via t('common.noData') | N/A | STATIC — i18n key is static, not dynamic |
|
|
66
|
+
|
|
67
|
+
### Behavioral Spot-Checks
|
|
68
|
+
|
|
69
|
+
| Behavior | Command | Result | Status |
|
|
70
|
+
| -------- | ------- | ------ | ------ |
|
|
71
|
+
| emptyText in interface | `grep -n "emptyText?: string" charts.tsx` | `864: emptyText?: string;` | PASS |
|
|
72
|
+
| No hardcoded Chinese | `grep -n "暂无数据" charts.tsx` | No matches | PASS |
|
|
73
|
+
| Ternary for coverage | `grep -n "coverageTrend.length.*?" ThinkingModelsPage.tsx` | `259: {data.coverageTrend.length >= 1 ?` | PASS |
|
|
74
|
+
| EmptyState for coverage | `grep -n "EmptyState" ThinkingModelsPage.tsx \| head -3` | Lines 276-279 with i18n keys | PASS |
|
|
75
|
+
| All LineChart have emptyText | `grep -B5 -A10 "<LineChart" ThinkingModelsPage.tsx \| grep -c "emptyText"` | 3 | PASS |
|
|
76
|
+
|
|
77
|
+
### Requirements Coverage
|
|
78
|
+
|
|
79
|
+
**IMPORTANT - Requirement Mismatch Found:**
|
|
80
|
+
|
|
81
|
+
| Requirement | Source | Description | Status | Evidence |
|
|
82
|
+
| ----------- | ------ | ----------- | ------ | -------- |
|
|
83
|
+
| SDK-CORE-03 | User-provided | Implement universal PainSignal interface logic | N/A | Does not belong to 01-basic-visualization phase |
|
|
84
|
+
| SDK-ADP-07 | User-provided | Implement Coding domain adapter | N/A | Does not belong to 01-basic-visualization phase |
|
|
85
|
+
| SDK-ADP-08 | User-provided | Implement second domain adapter | N/A | Does not belong to 01-basic-visualization phase |
|
|
86
|
+
| SDK-TEST-02 | User-provided | Implement full Adapter conformance test suite | N/A | Does not belong to 01-basic-visualization phase |
|
|
87
|
+
| SDK-TEST-03 | User-provided | Execute and publish performance benchmarks | N/A | Does not belong to 01-basic-visualization phase |
|
|
88
|
+
| SDK-MGMT-01 | User-provided | Package SDK as @principles/core npm package | N/A | Does not belong to 01-basic-visualization phase |
|
|
89
|
+
| SDK-MGMT-02 | User-provided | Establish Semver versioning and migration guides | N/A | Does not belong to 01-basic-visualization phase |
|
|
90
|
+
| VIZ-04 | GAP-CLOSURE-PLAN | Empty state optimization | VERIFIED | All 4 must_haves from GAP-CLOSURE-PLAN verified |
|
|
91
|
+
|
|
92
|
+
**The 7 requirement IDs provided are SDK Core Implementation Phase 1 requirements per REQUIREMENTS.md. They do not map to the 01-basic-visualization phase. The phase's actual scope is VIZ-04 (Empty state optimization), which has been verified through must_haves.**
|
|
93
|
+
|
|
94
|
+
### Anti-Patterns Found
|
|
95
|
+
|
|
96
|
+
| File | Line | Pattern | Severity | Impact |
|
|
97
|
+
| ---- | ---- | ------- | -------- | ------ |
|
|
98
|
+
| None | - | No anti-patterns detected | - | - |
|
|
99
|
+
|
|
100
|
+
### Human Verification Required
|
|
101
|
+
|
|
102
|
+
None required — all truths verifiable via static analysis.
|
|
103
|
+
|
|
104
|
+
### Gaps Summary
|
|
105
|
+
|
|
106
|
+
**None.** All 4 must_haves verified. Phase goal achieved.
|
|
107
|
+
|
|
108
|
+
**Notable finding:** The requirement IDs provided by user (SDK-CORE-03, SDK-ADP-07, SDK-ADP-08, SDK-TEST-02, SDK-TEST-03, SDK-MGMT-01, SDK-MGMT-02) are SDK Core Implementation Phase 1 requirements per REQUIREMENTS.md. This phase (01-basic-visualization) addresses VIZ-04 (Empty state optimization for visualization) - a different scope. Requirement coverage cannot be established for IDs that do not belong to this phase.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
_Verified: 2026-04-17T15:30:00Z_
|
|
113
|
+
_Verifier: Claude (gsd-verifier)_
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -82,7 +82,7 @@ export function bootstrapRules(stateDir: string, limit = 3): BootstrapResult[] {
|
|
|
82
82
|
// Migration: if T-01..T-10 exist in Training Store but not in Ledger Tree, backfill.
|
|
83
83
|
// This handles workspaces initialized before Ledger Tree was added.
|
|
84
84
|
const store = loadStore(stateDir);
|
|
85
|
-
|
|
85
|
+
let ledger = loadLedger(stateDir);
|
|
86
86
|
const hasTrainingT = Object.keys(store).some((id) => id.startsWith('T-'));
|
|
87
87
|
const hasAnyLedgerT = Object.keys(ledger.tree.principles).some((id) => id.startsWith('T-'));
|
|
88
88
|
if (hasTrainingT && !hasAnyLedgerT) {
|
|
@@ -115,6 +115,8 @@ export function bootstrapRules(stateDir: string, limit = 3): BootstrapResult[] {
|
|
|
115
115
|
};
|
|
116
116
|
addPrincipleToLedger(stateDir, lp);
|
|
117
117
|
}
|
|
118
|
+
// Reload ledger after migration so subsequent reads see the new data.
|
|
119
|
+
ledger = loadLedger(stateDir);
|
|
118
120
|
}
|
|
119
121
|
|
|
120
122
|
// Select principles for bootstrap
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* SAFETY:
|
|
16
16
|
* - Never load entire file (tail-only, max 512KB)
|
|
17
17
|
* - Skip lines > 100KB (real files have 11MB single lines)
|
|
18
|
-
* - Cap total output at
|
|
18
|
+
* - Cap total output at 2000 chars
|
|
19
19
|
* - All errors caught silently — return empty string on failure
|
|
20
20
|
*/
|
|
21
21
|
|
|
@@ -35,9 +35,9 @@ const TAIL_READ_SIZE = 512_000; // 512KB
|
|
|
35
35
|
/** Max turns to extract */
|
|
36
36
|
const MAX_TURNS = 8;
|
|
37
37
|
/** Max chars per turn entry */
|
|
38
|
-
const MAX_TURN_CHARS =
|
|
38
|
+
const MAX_TURN_CHARS = 400;
|
|
39
39
|
/** Max total output */
|
|
40
|
-
const MAX_OUTPUT_CHARS =
|
|
40
|
+
const MAX_OUTPUT_CHARS = 2000;
|
|
41
41
|
|
|
42
42
|
/** Valid characters for session IDs and agent IDs — prevents path traversal */
|
|
43
43
|
const SAFE_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
|
|
@@ -155,19 +155,24 @@ function extractTurn(msg: ParsedMessage): string | null {
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
if (msg.role === 'assistant') {
|
|
158
|
-
|
|
158
|
+
const parts: string[] = [];
|
|
159
|
+
|
|
160
|
+
// Priority 1: final text reply (if present)
|
|
159
161
|
if (msg.textParts.length > 0) {
|
|
160
162
|
const text = msg.textParts.join(' ').trim();
|
|
161
|
-
if (text)
|
|
163
|
+
if (text) parts.push(`[Assistant]: ${text.substring(0, MAX_TURN_CHARS)}`);
|
|
162
164
|
}
|
|
163
|
-
|
|
165
|
+
|
|
166
|
+
// Priority 2: tool call summary (always include if present, for context)
|
|
164
167
|
if (msg.toolCalls.length > 0) {
|
|
165
168
|
const tools = msg.toolCalls.map(tc => tc.name).filter(Boolean);
|
|
166
169
|
const uniqueTools = [...new Set(tools)];
|
|
167
170
|
if (uniqueTools.length > 0) {
|
|
168
|
-
|
|
171
|
+
parts.push(`[Assistant → ${uniqueTools.join(', ')}]`);
|
|
169
172
|
}
|
|
170
173
|
}
|
|
174
|
+
|
|
175
|
+
return parts.length > 0 ? parts.join(' ') : null;
|
|
171
176
|
}
|
|
172
177
|
|
|
173
178
|
if (msg.role === 'toolResult') {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import { resolvePdPath } from './paths.js';
|
|
3
|
+
|
|
4
|
+
export const PAIN_FLAG_FILENAME = '.pain_flag';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Removes the .pain_flag file from the workspace's .state directory.
|
|
8
|
+
* Called when a pain signal task completes (success, timeout, duplicate, or invalid)
|
|
9
|
+
* to prevent stale flags from triggering repeated processing.
|
|
10
|
+
*
|
|
11
|
+
* Optionally verifies the file content before deleting to prevent accidentally removing
|
|
12
|
+
* a concurrent new signal that was written between checkPainFlag reading the file and
|
|
13
|
+
* this deletion call (TOCTOU race).
|
|
14
|
+
*
|
|
15
|
+
* @param workspaceDir - Workspace directory
|
|
16
|
+
* @param expectedPainEventId - If provided, only deletes the file if its pain_event_id matches.
|
|
17
|
+
* This prevents deleting a newly written signal during a race window.
|
|
18
|
+
*/
|
|
19
|
+
export function clearPainFlag(workspaceDir: string, expectedPainEventId?: number | string): void {
|
|
20
|
+
const painFlagPath = resolvePdPath(workspaceDir, 'PAIN_FLAG');
|
|
21
|
+
try {
|
|
22
|
+
// Guard against TOCTOU race: if expectedPainEventId is provided,
|
|
23
|
+
// re-read the file and verify the pain_event_id matches before deleting.
|
|
24
|
+
// This prevents accidentally removing a new signal written between
|
|
25
|
+
// checkPainFlag reading the flag and this deletion.
|
|
26
|
+
if (expectedPainEventId !== undefined) {
|
|
27
|
+
const content = fs.readFileSync(painFlagPath, 'utf8');
|
|
28
|
+
const idMatch = content.includes(`pain_event_id: ${expectedPainEventId}`);
|
|
29
|
+
if (!idMatch) {
|
|
30
|
+
// File was rewritten with a different signal — do not delete.
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
fs.unlinkSync(painFlagPath);
|
|
35
|
+
} catch {
|
|
36
|
+
// Best-effort cleanup — ENOENT means already gone, other errors are ignored.
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/core/pain-signal.ts
CHANGED
|
@@ -48,11 +48,11 @@ export const PainSignalSchema = Type.Object({
|
|
|
48
48
|
/** Human-readable reason / error description */
|
|
49
49
|
reason: Type.String({ minLength: 1 }),
|
|
50
50
|
/** Session ID — identifies which conversation this happened in */
|
|
51
|
-
sessionId: Type.String(
|
|
51
|
+
sessionId: Type.Optional(Type.String()),
|
|
52
52
|
/** Agent ID — identifies which agent (main, builder, diagnostician, etc.) */
|
|
53
|
-
agentId: Type.String(
|
|
53
|
+
agentId: Type.Optional(Type.String()),
|
|
54
54
|
/** Correlation trace ID for linking events across the pipeline */
|
|
55
|
-
traceId: Type.String(
|
|
55
|
+
traceId: Type.Optional(Type.String()),
|
|
56
56
|
/** Preview of the text that triggered this pain */
|
|
57
57
|
triggerTextPreview: Type.String(),
|
|
58
58
|
/** Domain context (e.g., 'coding', 'writing', 'analysis') */
|
|
@@ -111,6 +111,9 @@ export function validatePainSignal(input: unknown): PainSignalValidationResult {
|
|
|
111
111
|
const hydrated = {
|
|
112
112
|
...raw,
|
|
113
113
|
domain: raw.domain ?? 'coding',
|
|
114
|
+
sessionId: raw.sessionId ?? undefined,
|
|
115
|
+
agentId: raw.agentId ?? undefined,
|
|
116
|
+
traceId: raw.traceId ?? undefined,
|
|
114
117
|
severity: raw.severity ?? deriveSeverity(
|
|
115
118
|
typeof raw.score === 'number' ? raw.score : 0,
|
|
116
119
|
),
|
|
@@ -179,8 +179,16 @@ export async function auditEventLogs(
|
|
|
179
179
|
recentEntries: recent,
|
|
180
180
|
});
|
|
181
181
|
|
|
182
|
-
// Determine primary path
|
|
183
|
-
|
|
182
|
+
// Determine primary path - prefer configured workspace over workspace-main
|
|
183
|
+
// The configured workspace path is {openclawDir}/workspace (without -main suffix)
|
|
184
|
+
const workspaceDir = path.join(openclawDir, 'workspace') + path.sep;
|
|
185
|
+
const workspaceMainDir = path.join(openclawDir, 'workspace-main') + path.sep;
|
|
186
|
+
|
|
187
|
+
if (filePath.startsWith(workspaceDir)) {
|
|
188
|
+
// Configured workspace (e.g., ~/.openclaw/workspace/) takes priority
|
|
189
|
+
primaryPath = filePath;
|
|
190
|
+
} else if (!primaryPath && filePath.startsWith(workspaceMainDir)) {
|
|
191
|
+
// Fallback to workspace-main only if no configured workspace found yet
|
|
184
192
|
primaryPath = filePath;
|
|
185
193
|
}
|
|
186
194
|
} catch {
|
|
@@ -52,6 +52,7 @@ import { CorrectionCueLearner } from '../core/correction-cue-learner.js';
|
|
|
52
52
|
import { classifyFailure, type ClassifiableTaskKind } from './failure-classifier.js';
|
|
53
53
|
import { recordPersistentFailure, resetFailureState, isTaskKindInCooldown } from './cooldown-strategy.js';
|
|
54
54
|
import { reconcileStartup } from './startup-reconciler.js';
|
|
55
|
+
import { clearPainFlag } from '../core/pain-lifecycle.js';
|
|
55
56
|
import { WORKFLOW_TTL_MS } from '../config/defaults/runtime.js';
|
|
56
57
|
import { OpenClawTrinityRuntimeAdapter } from '../core/nocturnal-trinity.js';
|
|
57
58
|
|
|
@@ -338,9 +339,9 @@ async function doEnqueuePainTask(
|
|
|
338
339
|
score: v.score,
|
|
339
340
|
timestamp: new Date().toISOString(),
|
|
340
341
|
reason: v.reason,
|
|
341
|
-
sessionId: v.sessionId
|
|
342
|
-
agentId: v.agentId
|
|
343
|
-
traceId: v.traceId
|
|
342
|
+
sessionId: v.sessionId ?? undefined,
|
|
343
|
+
agentId: v.agentId ?? undefined,
|
|
344
|
+
traceId: v.traceId ?? undefined,
|
|
344
345
|
triggerTextPreview: v.preview,
|
|
345
346
|
};
|
|
346
347
|
const validation: PainSignalValidationResult = validatePainSignal(signalInput);
|
|
@@ -348,6 +349,7 @@ async function doEnqueuePainTask(
|
|
|
348
349
|
result.skipped_reason = `invalid_pain_signal (${validation.errors.join('; ')})`;
|
|
349
350
|
if (logger) logger.warn(`[PD:EvolutionWorker] Pain signal validation failed, skipping enqueue: ${validation.errors.join('; ')}`);
|
|
350
351
|
SystemLogger.log(wctx.workspaceDir, 'PAIN_SIGNAL_INVALID', `Validation errors: ${validation.errors.join('; ')} | source=${v.source} score=${v.score}`);
|
|
352
|
+
clearPainFlag(wctx.workspaceDir);
|
|
351
353
|
return result;
|
|
352
354
|
}
|
|
353
355
|
|
|
@@ -365,6 +367,7 @@ async function doEnqueuePainTask(
|
|
|
365
367
|
result.enqueued = true;
|
|
366
368
|
result.skipped_reason = 'duplicate';
|
|
367
369
|
if (logger) logger.info(`[PD:EvolutionWorker] Duplicate pain task skipped for source=${v.source} preview=${v.preview || 'N/A'}`);
|
|
370
|
+
clearPainFlag(wctx.workspaceDir);
|
|
368
371
|
return result;
|
|
369
372
|
}
|
|
370
373
|
|
|
@@ -408,6 +411,7 @@ async function doEnqueuePainTask(
|
|
|
408
411
|
enqueuedAt: nowIso,
|
|
409
412
|
});
|
|
410
413
|
} finally { releaseLock(); }
|
|
414
|
+
clearPainFlag(wctx.workspaceDir);
|
|
411
415
|
return result;
|
|
412
416
|
}
|
|
413
417
|
|
|
@@ -440,6 +444,7 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
|
|
|
440
444
|
if (isQueued) {
|
|
441
445
|
result.skipped_reason = 'already_queued';
|
|
442
446
|
if (logger) logger.info(`[PD:EvolutionWorker] Pain flag already queued (score=${score}, source=${source})`);
|
|
447
|
+
clearPainFlag(wctx.workspaceDir, painEventId);
|
|
443
448
|
return result;
|
|
444
449
|
}
|
|
445
450
|
|
|
@@ -453,6 +458,7 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
|
|
|
453
458
|
result.exists = true;
|
|
454
459
|
result.skipped_reason = `invalid_pain_flag (${contract.missingFields.join(', ') || contract.format})`;
|
|
455
460
|
if (logger) logger.warn(`[PD:EvolutionWorker] Invalid pain flag skipped: ${result.skipped_reason}`);
|
|
461
|
+
clearPainFlag(wctx.workspaceDir);
|
|
456
462
|
return result;
|
|
457
463
|
}
|
|
458
464
|
|
|
@@ -493,6 +499,7 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
|
|
|
493
499
|
result.enqueued = true;
|
|
494
500
|
result.skipped_reason = 'already_queued';
|
|
495
501
|
if (logger) logger.info(`[PD:EvolutionWorker] Pain flag already queued (score=${jsonScore}, source=${jsonSource})`);
|
|
502
|
+
clearPainFlag(wctx.workspaceDir, jsonPain.pain_event_id ? parseInt(jsonPain.pain_event_id, 10) : undefined);
|
|
496
503
|
return result;
|
|
497
504
|
}
|
|
498
505
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { clearPainFlag, PAIN_FLAG_FILENAME } from '../../src/core/pain-lifecycle.js';
|
|
5
|
+
import { resolvePdPath } from '../../src/core/paths.js';
|
|
6
|
+
|
|
7
|
+
describe('PainLifecycle', () => {
|
|
8
|
+
const workspaceDir = fs.mkdtempSync(path.join(fs.realpathSync('/tmp'), 'pain-lifecycle-test-'));
|
|
9
|
+
const painFlagPath = resolvePdPath(workspaceDir, 'PAIN_FLAG');
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
const stateDir = path.dirname(painFlagPath);
|
|
13
|
+
if (!fs.existsSync(stateDir)) {
|
|
14
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
if (fs.existsSync(painFlagPath)) fs.unlinkSync(painFlagPath);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should delete .pain_flag file when it exists', () => {
|
|
23
|
+
fs.writeFileSync(painFlagPath, 'source: test\nscore: 80\nreason: test\ntime: 2026-01-01\n', 'utf8');
|
|
24
|
+
expect(fs.existsSync(painFlagPath)).toBe(true);
|
|
25
|
+
clearPainFlag(workspaceDir);
|
|
26
|
+
expect(fs.existsSync(painFlagPath)).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should not throw when .pain_flag does not exist', () => {
|
|
30
|
+
expect(fs.existsSync(painFlagPath)).toBe(false);
|
|
31
|
+
expect(() => clearPainFlag(workspaceDir)).not.toThrow();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should export correct filename constant', () => {
|
|
35
|
+
expect(PAIN_FLAG_FILENAME).toBe('.pain_flag');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -66,14 +66,14 @@ describe('PainSignalSchema', () => {
|
|
|
66
66
|
expect(Value.Check(PainSignalSchema, signal)).toBe(false);
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
it('
|
|
69
|
+
it('accepts empty optional fields (sessionId, agentId, etc.)', () => {
|
|
70
70
|
const signal = validSignal({
|
|
71
71
|
sessionId: '',
|
|
72
72
|
agentId: '',
|
|
73
73
|
traceId: '',
|
|
74
74
|
triggerTextPreview: '',
|
|
75
75
|
});
|
|
76
|
-
expect(Value.Check(PainSignalSchema, signal)).toBe(
|
|
76
|
+
expect(Value.Check(PainSignalSchema, signal)).toBe(true);
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
it('accepts any string for domain', () => {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { clearPainFlag } from '../../src/core/pain-lifecycle.js';
|
|
5
|
+
import { resolvePdPath } from '../../src/core/paths.js';
|
|
6
|
+
|
|
7
|
+
describe('Pain Lifecycle E2E', () => {
|
|
8
|
+
const workspaceDir = fs.mkdtempSync(path.join(fs.realpathSync('/tmp'), 'pain-lifecycle-e2e-'));
|
|
9
|
+
const painFlagPath = resolvePdPath(workspaceDir, 'PAIN_FLAG');
|
|
10
|
+
const stateDir = path.dirname(painFlagPath);
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
if (!fs.existsSync(stateDir)) fs.mkdirSync(stateDir, { recursive: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
if (fs.existsSync(painFlagPath)) fs.unlinkSync(painFlagPath);
|
|
18
|
+
if (fs.existsSync(stateDir)) {
|
|
19
|
+
try { fs.rmSync(stateDir, { recursive: true }); } catch { /* ignore */ }
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should clear pain_flag after writing a valid flag', () => {
|
|
24
|
+
// Simulate write_pain_flag tool writing a flag
|
|
25
|
+
const kvContent = [
|
|
26
|
+
'source: tool_failure',
|
|
27
|
+
'score: 85',
|
|
28
|
+
'reason: Test pain signal',
|
|
29
|
+
'time: 2026-04-17T00:00:00.000Z',
|
|
30
|
+
].join('\n') + '\n';
|
|
31
|
+
fs.writeFileSync(painFlagPath, kvContent, 'utf8');
|
|
32
|
+
expect(fs.existsSync(painFlagPath)).toBe(true);
|
|
33
|
+
|
|
34
|
+
// Simulate task enqueue → clear on exit path
|
|
35
|
+
clearPainFlag(workspaceDir);
|
|
36
|
+
expect(fs.existsSync(painFlagPath)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should be idempotent — clearing twice should not throw', () => {
|
|
40
|
+
fs.writeFileSync(painFlagPath, 'source: test\nscore: 50\nreason: idem\ntime: 2026-01-01\n', 'utf8');
|
|
41
|
+
expect(() => clearPainFlag(workspaceDir)).not.toThrow();
|
|
42
|
+
expect(() => clearPainFlag(workspaceDir)).not.toThrow();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should not throw when .state directory does not exist', () => {
|
|
46
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
47
|
+
expect(() => clearPainFlag(workspaceDir)).not.toThrow();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should NOT delete file when expectedPainEventId does not match (concurrent rewrite guard)', () => {
|
|
51
|
+
// Write a flag with pain_event_id: 5
|
|
52
|
+
fs.writeFileSync(painFlagPath, 'source: test\nscore: 80\nreason: old\ntime: 2026-01-01\npain_event_id: 5\n', 'utf8');
|
|
53
|
+
expect(fs.existsSync(painFlagPath)).toBe(true);
|
|
54
|
+
|
|
55
|
+
// Simulate: another write_pain_flag runs and writes a NEW signal (pain_event_id: 7)
|
|
56
|
+
// before our clearPainFlag with expected id=5 runs
|
|
57
|
+
fs.writeFileSync(painFlagPath, 'source: test\nscore: 90\nreason: new\ntime: 2026-01-02\npain_event_id: 7\n', 'utf8');
|
|
58
|
+
|
|
59
|
+
// clearPainFlag with expected id=5 should NOT delete the new signal (id=7)
|
|
60
|
+
clearPainFlag(workspaceDir, 5);
|
|
61
|
+
expect(fs.existsSync(painFlagPath)).toBe(true);
|
|
62
|
+
|
|
63
|
+
// The file should still contain the new signal
|
|
64
|
+
const remaining = fs.readFileSync(painFlagPath, 'utf8');
|
|
65
|
+
expect(remaining).toContain('pain_event_id: 7');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should delete file when expectedPainEventId matches', () => {
|
|
69
|
+
fs.writeFileSync(painFlagPath, 'source: test\nscore: 80\nreason: idem\ntime: 2026-01-01\npain_event_id: 42\n', 'utf8');
|
|
70
|
+
expect(fs.existsSync(painFlagPath)).toBe(true);
|
|
71
|
+
clearPainFlag(workspaceDir, 42);
|
|
72
|
+
expect(fs.existsSync(painFlagPath)).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
});
|