gsd-pi 2.23.0 → 2.24.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/dist/cli.js +12 -3
- package/dist/headless.d.ts +4 -0
- package/dist/headless.js +118 -10
- package/dist/help-text.js +22 -7
- package/dist/resource-loader.js +64 -9
- package/dist/resources/extensions/gsd/auto-dispatch.ts +51 -2
- package/dist/resources/extensions/gsd/auto-prompts.ts +73 -0
- package/dist/resources/extensions/gsd/auto-recovery.ts +41 -2
- package/dist/resources/extensions/gsd/auto-worktree.ts +15 -3
- package/dist/resources/extensions/gsd/auto.ts +123 -41
- package/dist/resources/extensions/gsd/commands.ts +176 -10
- package/dist/resources/extensions/gsd/complexity.ts +1 -0
- package/dist/resources/extensions/gsd/dashboard-overlay.ts +38 -0
- package/dist/resources/extensions/gsd/doctor.ts +56 -11
- package/dist/resources/extensions/gsd/exit-command.ts +2 -2
- package/dist/resources/extensions/gsd/gitignore.ts +1 -0
- package/dist/resources/extensions/gsd/guided-flow.ts +75 -0
- package/dist/resources/extensions/gsd/index.ts +34 -1
- package/dist/resources/extensions/gsd/parallel-eligibility.ts +233 -0
- package/dist/resources/extensions/gsd/parallel-merge.ts +156 -0
- package/dist/resources/extensions/gsd/parallel-orchestrator.ts +496 -0
- package/dist/resources/extensions/gsd/preferences.ts +65 -1
- package/dist/resources/extensions/gsd/prompts/discuss-headless.md +86 -0
- package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
- package/dist/resources/extensions/gsd/prompts/validate-milestone.md +40 -61
- package/dist/resources/extensions/gsd/provider-error-pause.ts +29 -2
- package/dist/resources/extensions/gsd/session-status-io.ts +197 -0
- package/dist/resources/extensions/gsd/state.ts +72 -30
- package/dist/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +81 -0
- package/dist/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +20 -3
- package/dist/resources/extensions/gsd/tests/auto-preflight.test.ts +1 -0
- package/dist/resources/extensions/gsd/tests/auto-recovery.test.ts +202 -2
- package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +34 -0
- package/dist/resources/extensions/gsd/tests/complete-milestone.test.ts +8 -1
- package/dist/resources/extensions/gsd/tests/derive-state-db.test.ts +9 -15
- package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +9 -0
- package/dist/resources/extensions/gsd/tests/derive-state-draft.test.ts +8 -0
- package/dist/resources/extensions/gsd/tests/derive-state.test.ts +14 -0
- package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +8 -0
- package/dist/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +5 -5
- package/dist/resources/extensions/gsd/tests/parallel-orchestration.test.ts +656 -0
- package/dist/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +354 -0
- package/dist/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +1 -0
- package/dist/resources/extensions/gsd/tests/validate-milestone.test.ts +316 -0
- package/dist/resources/extensions/gsd/tests/worker-registry.test.ts +148 -0
- package/dist/resources/extensions/gsd/types.ts +15 -1
- package/dist/resources/extensions/subagent/index.ts +5 -0
- package/dist/resources/extensions/subagent/worker-registry.ts +99 -0
- package/dist/update-check.d.ts +9 -0
- package/dist/update-check.js +97 -0
- package/package.json +6 -1
- package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/anthropic.js +16 -7
- package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
- package/packages/pi-ai/dist/providers/azure-openai-responses.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/azure-openai-responses.js +12 -4
- package/packages/pi-ai/dist/providers/azure-openai-responses.js.map +1 -1
- package/packages/pi-ai/dist/providers/google-vertex.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/google-vertex.js +21 -9
- package/packages/pi-ai/dist/providers/google-vertex.js.map +1 -1
- package/packages/pi-ai/dist/providers/openai-completions.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/openai-completions.js +12 -4
- package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
- package/packages/pi-ai/dist/providers/openai-responses.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/openai-responses.js +12 -4
- package/packages/pi-ai/dist/providers/openai-responses.js.map +1 -1
- package/packages/pi-ai/src/providers/anthropic.ts +21 -8
- package/packages/pi-ai/src/providers/azure-openai-responses.ts +16 -4
- package/packages/pi-ai/src/providers/google-vertex.ts +32 -17
- package/packages/pi-ai/src/providers/openai-completions.ts +16 -4
- package/packages/pi-ai/src/providers/openai-responses.ts +16 -4
- package/packages/pi-coding-agent/dist/core/agent-session.js +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/settings-manager.js +1 -1
- package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
- package/packages/pi-coding-agent/src/core/agent-session.ts +1 -1
- package/packages/pi-coding-agent/src/core/settings-manager.ts +2 -2
- package/scripts/postinstall.js +7 -109
- package/src/resources/extensions/gsd/auto-dispatch.ts +51 -2
- package/src/resources/extensions/gsd/auto-prompts.ts +73 -0
- package/src/resources/extensions/gsd/auto-recovery.ts +41 -2
- package/src/resources/extensions/gsd/auto-worktree.ts +15 -3
- package/src/resources/extensions/gsd/auto.ts +123 -41
- package/src/resources/extensions/gsd/commands.ts +176 -10
- package/src/resources/extensions/gsd/complexity.ts +1 -0
- package/src/resources/extensions/gsd/dashboard-overlay.ts +38 -0
- package/src/resources/extensions/gsd/doctor.ts +56 -11
- package/src/resources/extensions/gsd/exit-command.ts +2 -2
- package/src/resources/extensions/gsd/gitignore.ts +1 -0
- package/src/resources/extensions/gsd/guided-flow.ts +75 -0
- package/src/resources/extensions/gsd/index.ts +34 -1
- package/src/resources/extensions/gsd/parallel-eligibility.ts +233 -0
- package/src/resources/extensions/gsd/parallel-merge.ts +156 -0
- package/src/resources/extensions/gsd/parallel-orchestrator.ts +496 -0
- package/src/resources/extensions/gsd/preferences.ts +65 -1
- package/src/resources/extensions/gsd/prompts/discuss-headless.md +86 -0
- package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
- package/src/resources/extensions/gsd/prompts/validate-milestone.md +40 -61
- package/src/resources/extensions/gsd/provider-error-pause.ts +29 -2
- package/src/resources/extensions/gsd/session-status-io.ts +197 -0
- package/src/resources/extensions/gsd/state.ts +72 -30
- package/src/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +81 -0
- package/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +20 -3
- package/src/resources/extensions/gsd/tests/auto-preflight.test.ts +1 -0
- package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +202 -2
- package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +34 -0
- package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +8 -1
- package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +9 -15
- package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +9 -0
- package/src/resources/extensions/gsd/tests/derive-state-draft.test.ts +8 -0
- package/src/resources/extensions/gsd/tests/derive-state.test.ts +14 -0
- package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +8 -0
- package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +5 -5
- package/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts +656 -0
- package/src/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +354 -0
- package/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +1 -0
- package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +316 -0
- package/src/resources/extensions/gsd/tests/worker-registry.test.ts +148 -0
- package/src/resources/extensions/gsd/types.ts +15 -1
- package/src/resources/extensions/subagent/index.ts +5 -0
- package/src/resources/extensions/subagent/worker-registry.ts +99 -0
|
@@ -46,7 +46,7 @@ Research what this slice needs. Narrate key findings and surprises as you go —
|
|
|
46
46
|
2. **Skill Discovery ({{skillDiscoveryMode}}):**{{skillDiscoveryInstructions}}
|
|
47
47
|
3. Explore relevant code for this slice's scope. For targeted exploration, use `rg`, `find`, and reads. For broad or unfamiliar subsystems, use `scout` to map the relevant area first.
|
|
48
48
|
4. Use `resolve_library` / `get_library_docs` for unfamiliar libraries — skip this for libraries already used in the codebase
|
|
49
|
-
5. Use the **Research** output template from the inlined context above — include only sections that have real content
|
|
49
|
+
5. Use the **Research** output template from the inlined context above — include only sections that have real content. The template is already inlined above; do NOT attempt to read any template file from disk (there is no `templates/SLICE-RESEARCH.md` — the correct template is already present in this prompt).
|
|
50
50
|
6. Write `{{outputPath}}`
|
|
51
51
|
|
|
52
52
|
The slice directory already exists at `{{slicePath}}/`. Do NOT mkdir — just write the file.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
You are executing GSD auto-mode.
|
|
2
2
|
|
|
3
|
-
## UNIT: Validate Milestone {{milestoneId}} ("{{milestoneTitle}}")
|
|
3
|
+
## UNIT: Validate Milestone {{milestoneId}} ("{{milestoneTitle}}")
|
|
4
4
|
|
|
5
5
|
## Working Directory
|
|
6
6
|
|
|
@@ -8,84 +8,63 @@ Your working directory is `{{workingDirectory}}`. All file reads, writes, and sh
|
|
|
8
8
|
|
|
9
9
|
## Your Role in the Pipeline
|
|
10
10
|
|
|
11
|
-
All slices are done. Before the
|
|
11
|
+
All slices are done. Before the milestone can be completed, you must validate that the planned work was delivered as specified. Compare the roadmap's success criteria and slice definitions against the actual slice summaries and UAT results. This is a reconciliation gate — catch gaps, regressions, or missing deliverables before the milestone is sealed.
|
|
12
12
|
|
|
13
|
-
This is
|
|
13
|
+
This is remediation round {{remediationRound}}. If this is round 0, this is the first validation pass. If > 0, prior validation found issues and remediation slices were added and executed — verify those remediation slices resolved the issues.
|
|
14
14
|
|
|
15
15
|
All relevant context has been preloaded below — the roadmap, all slice summaries, UAT results, requirements, decisions, and project context are inlined. Start working immediately without re-reading these files.
|
|
16
16
|
|
|
17
17
|
{{inlinedContext}}
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
## Validation Steps
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
1. For each **success criterion** in `{{roadmapPath}}`, check whether slice summaries and UAT results provide evidence that it was met. Record pass/fail per criterion.
|
|
22
|
+
2. For each **slice** in the roadmap, verify its demo/deliverable claim against its summary. Flag any slice whose summary does not substantiate its claimed output.
|
|
23
|
+
3. Check **cross-slice integration points** — do boundary map entries (produces/consumes) align with what was actually built?
|
|
24
|
+
4. Check **requirement coverage** — are all active requirements addressed by at least one slice?
|
|
25
|
+
5. Determine a verdict:
|
|
26
|
+
- `pass` — all criteria met, all slices delivered, no gaps
|
|
27
|
+
- `needs-attention` — minor gaps that do not block completion (document them)
|
|
28
|
+
- `needs-remediation` — material gaps found; add remediation slices to the roadmap
|
|
22
29
|
|
|
23
|
-
|
|
30
|
+
## Output
|
|
24
31
|
|
|
25
|
-
|
|
32
|
+
Write `{{validationPath}}` with this structure:
|
|
26
33
|
|
|
27
|
-
|
|
34
|
+
```markdown
|
|
35
|
+
---
|
|
36
|
+
verdict: <pass|needs-attention|needs-remediation>
|
|
37
|
+
remediation_round: {{remediationRound}}
|
|
38
|
+
---
|
|
28
39
|
|
|
29
|
-
|
|
30
|
-
- `Criterion text` — **NOT MET** — gap: {{what's missing and why}}
|
|
40
|
+
# Milestone Validation: {{milestoneId}}
|
|
31
41
|
|
|
32
|
-
|
|
42
|
+
## Success Criteria Checklist
|
|
43
|
+
- [x] Criterion 1 — evidence: ...
|
|
44
|
+
- [ ] Criterion 2 — gap: ...
|
|
33
45
|
|
|
34
|
-
|
|
46
|
+
## Slice Delivery Audit
|
|
47
|
+
| Slice | Claimed | Delivered | Status |
|
|
48
|
+
|-------|---------|-----------|--------|
|
|
49
|
+
| S01 | ... | ... | pass |
|
|
35
50
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
- `Follow-ups` sections
|
|
39
|
-
- `Deviations` sections
|
|
51
|
+
## Cross-Slice Integration
|
|
52
|
+
(any boundary mismatches)
|
|
40
53
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
- Any PARTIAL or FAIL verdicts
|
|
54
|
+
## Requirement Coverage
|
|
55
|
+
(any unaddressed requirements)
|
|
44
56
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
- `.gsd/CAPTURES.md` for unresolved deferred captures
|
|
57
|
+
## Verdict Rationale
|
|
58
|
+
(why this verdict was chosen)
|
|
48
59
|
|
|
49
|
-
|
|
60
|
+
## Remediation Plan
|
|
61
|
+
(only if verdict is needs-remediation — list new slices to add to the roadmap)
|
|
62
|
+
```
|
|
50
63
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
- **auto-remediable** — can be fixed by adding a new slice (missing feature, unfixed bug, untested path, incomplete integration)
|
|
56
|
-
- **human-required** — needs Lex's input (design decision, external service dependency, manual verification, judgment call, ambiguous requirement)
|
|
57
|
-
- **acceptable** — known limitation that's OK to ship (documented trade-off, explicitly scoped for a future milestone, minor rough edge with no user impact)
|
|
58
|
-
|
|
59
|
-
Be conservative with **auto-remediable**. Only classify a gap as auto-remediable if you're confident a slice can resolve it without human judgment. When in doubt, classify as **human-required**.
|
|
60
|
-
|
|
61
|
-
### Step 4: Act on Gaps
|
|
62
|
-
|
|
63
|
-
**If this is remediation round 0 AND auto-remediable gaps exist:**
|
|
64
|
-
|
|
65
|
-
1. Define remediation slices to address auto-remediable gaps. Follow the exact roadmap slice format:
|
|
66
|
-
`- [ ] **S0X: Title** \`risk:medium\` \`depends:[]\``
|
|
67
|
-
Include a brief description of what each slice must accomplish.
|
|
68
|
-
2. Append these slices to `{{roadmapPath}}` after existing slices (do not modify completed slices).
|
|
69
|
-
3. Update the boundary map in the roadmap if the new slices introduce new integration points.
|
|
70
|
-
4. Set verdict to `needs-remediation`.
|
|
71
|
-
|
|
72
|
-
**If this is remediation round 1 or higher:**
|
|
73
|
-
|
|
74
|
-
Do NOT add more slices. At this point either:
|
|
75
|
-
- All remaining gaps are acceptable — set verdict to `pass`
|
|
76
|
-
- Remaining gaps need Lex's input — set verdict to `needs-attention`
|
|
77
|
-
|
|
78
|
-
Never add remediation slices after round 0. If round 0 remediation didn't close the gaps, escalate.
|
|
79
|
-
|
|
80
|
-
**If no auto-remediable gaps exist (any round):**
|
|
81
|
-
|
|
82
|
-
- If all criteria are MET and deferred items are acceptable or human-required only — set verdict to `pass` (with human-required items noted)
|
|
83
|
-
- If human-required items are blocking — set verdict to `needs-attention`
|
|
84
|
-
|
|
85
|
-
### Step 5: Write Validation Report
|
|
86
|
-
|
|
87
|
-
Write `{{validationPath}}` using the milestone-validation template. Fill all frontmatter fields and every section. The report must be a complete record of the validation — a future agent reading only this file should understand what was checked, what passed, and what remains.
|
|
64
|
+
If verdict is `needs-remediation`:
|
|
65
|
+
- Add new slices to `{{roadmapPath}}` with unchecked `[ ]` status
|
|
66
|
+
- These slices will be planned and executed before validation re-runs
|
|
88
67
|
|
|
89
68
|
**You MUST write `{{validationPath}}` before finishing.**
|
|
90
69
|
|
|
91
|
-
When done, say: "Milestone {{milestoneId}}
|
|
70
|
+
When done, say: "Milestone {{milestoneId}} validation complete — verdict: <verdict>."
|
|
@@ -2,11 +2,38 @@ export type ProviderErrorPauseUI = {
|
|
|
2
2
|
notify(message: string, level?: "info" | "warning" | "error" | "success"): void;
|
|
3
3
|
};
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Pause auto-mode due to a provider error.
|
|
7
|
+
*
|
|
8
|
+
* For rate-limit errors with a known reset delay, schedules an automatic
|
|
9
|
+
* resume after the delay and shows a countdown notification. For all other
|
|
10
|
+
* errors, pauses indefinitely (user must manually resume).
|
|
11
|
+
*/
|
|
5
12
|
export async function pauseAutoForProviderError(
|
|
6
13
|
ui: ProviderErrorPauseUI,
|
|
7
14
|
errorDetail: string,
|
|
8
15
|
pause: () => Promise<void>,
|
|
16
|
+
options?: {
|
|
17
|
+
isRateLimit?: boolean;
|
|
18
|
+
retryAfterMs?: number;
|
|
19
|
+
resume?: () => void;
|
|
20
|
+
},
|
|
9
21
|
): Promise<void> {
|
|
10
|
-
|
|
11
|
-
|
|
22
|
+
if (options?.isRateLimit && options.retryAfterMs && options.retryAfterMs > 0 && options.resume) {
|
|
23
|
+
const delaySec = Math.ceil(options.retryAfterMs / 1000);
|
|
24
|
+
ui.notify(
|
|
25
|
+
`Rate limited${errorDetail}. Auto-resuming in ${delaySec}s...`,
|
|
26
|
+
"warning",
|
|
27
|
+
);
|
|
28
|
+
await pause();
|
|
29
|
+
|
|
30
|
+
// Schedule auto-resume after the rate limit window
|
|
31
|
+
setTimeout(() => {
|
|
32
|
+
ui.notify("Rate limit window elapsed. Resuming auto-mode.", "info");
|
|
33
|
+
options.resume!();
|
|
34
|
+
}, options.retryAfterMs);
|
|
35
|
+
} else {
|
|
36
|
+
ui.notify(`Auto-mode paused due to provider error${errorDetail}`, "warning");
|
|
37
|
+
await pause();
|
|
38
|
+
}
|
|
12
39
|
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSD Session Status I/O
|
|
3
|
+
*
|
|
4
|
+
* File-based IPC protocol for coordinator-worker communication in
|
|
5
|
+
* parallel milestone orchestration. Each worker writes its status to a
|
|
6
|
+
* file; the coordinator reads all status files to monitor progress.
|
|
7
|
+
*
|
|
8
|
+
* Atomic writes (write to .tmp, then rename) prevent partial reads.
|
|
9
|
+
* Signal files let the coordinator send pause/resume/stop/rebase to workers.
|
|
10
|
+
* Stale detection combines PID liveness checks with heartbeat timeouts.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
writeFileSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
renameSync,
|
|
17
|
+
unlinkSync,
|
|
18
|
+
readdirSync,
|
|
19
|
+
mkdirSync,
|
|
20
|
+
existsSync,
|
|
21
|
+
} from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { gsdRoot } from "./paths.js";
|
|
24
|
+
|
|
25
|
+
// ─── Types ─────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface SessionStatus {
|
|
28
|
+
milestoneId: string;
|
|
29
|
+
pid: number;
|
|
30
|
+
state: "running" | "paused" | "stopped" | "error";
|
|
31
|
+
currentUnit: { type: string; id: string; startedAt: number } | null;
|
|
32
|
+
completedUnits: number;
|
|
33
|
+
cost: number;
|
|
34
|
+
lastHeartbeat: number;
|
|
35
|
+
startedAt: number;
|
|
36
|
+
worktreePath: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type SessionSignal = "pause" | "resume" | "stop" | "rebase";
|
|
40
|
+
|
|
41
|
+
export interface SignalMessage {
|
|
42
|
+
signal: SessionSignal;
|
|
43
|
+
sentAt: number;
|
|
44
|
+
from: "coordinator";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── Constants ─────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
const PARALLEL_DIR = "parallel";
|
|
50
|
+
const STATUS_SUFFIX = ".status.json";
|
|
51
|
+
const SIGNAL_SUFFIX = ".signal.json";
|
|
52
|
+
const TMP_SUFFIX = ".tmp";
|
|
53
|
+
const DEFAULT_STALE_TIMEOUT_MS = 30_000;
|
|
54
|
+
|
|
55
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function parallelDir(basePath: string): string {
|
|
58
|
+
return join(gsdRoot(basePath), PARALLEL_DIR);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function statusPath(basePath: string, milestoneId: string): string {
|
|
62
|
+
return join(parallelDir(basePath), `${milestoneId}${STATUS_SUFFIX}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function signalPath(basePath: string, milestoneId: string): string {
|
|
66
|
+
return join(parallelDir(basePath), `${milestoneId}${SIGNAL_SUFFIX}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ensureParallelDir(basePath: string): void {
|
|
70
|
+
const dir = parallelDir(basePath);
|
|
71
|
+
if (!existsSync(dir)) {
|
|
72
|
+
mkdirSync(dir, { recursive: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isPidAlive(pid: number): boolean {
|
|
77
|
+
try {
|
|
78
|
+
process.kill(pid, 0);
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── Status I/O ────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/** Write session status atomically (write to .tmp, then rename). */
|
|
88
|
+
export function writeSessionStatus(basePath: string, status: SessionStatus): void {
|
|
89
|
+
try {
|
|
90
|
+
ensureParallelDir(basePath);
|
|
91
|
+
const dest = statusPath(basePath, status.milestoneId);
|
|
92
|
+
const tmp = dest + TMP_SUFFIX;
|
|
93
|
+
writeFileSync(tmp, JSON.stringify(status, null, 2), "utf-8");
|
|
94
|
+
renameSync(tmp, dest);
|
|
95
|
+
} catch { /* non-fatal */ }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Read a specific milestone's session status. */
|
|
99
|
+
export function readSessionStatus(basePath: string, milestoneId: string): SessionStatus | null {
|
|
100
|
+
try {
|
|
101
|
+
const p = statusPath(basePath, milestoneId);
|
|
102
|
+
if (!existsSync(p)) return null;
|
|
103
|
+
const raw = readFileSync(p, "utf-8");
|
|
104
|
+
return JSON.parse(raw) as SessionStatus;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Read all session status files from .gsd/parallel/. */
|
|
111
|
+
export function readAllSessionStatuses(basePath: string): SessionStatus[] {
|
|
112
|
+
const dir = parallelDir(basePath);
|
|
113
|
+
if (!existsSync(dir)) return [];
|
|
114
|
+
|
|
115
|
+
const results: SessionStatus[] = [];
|
|
116
|
+
try {
|
|
117
|
+
const entries = readdirSync(dir);
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
if (!entry.endsWith(STATUS_SUFFIX)) continue;
|
|
120
|
+
try {
|
|
121
|
+
const raw = readFileSync(join(dir, entry), "utf-8");
|
|
122
|
+
results.push(JSON.parse(raw) as SessionStatus);
|
|
123
|
+
} catch { /* skip corrupt files */ }
|
|
124
|
+
}
|
|
125
|
+
} catch { /* non-fatal */ }
|
|
126
|
+
return results;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Remove a milestone's session status file. */
|
|
130
|
+
export function removeSessionStatus(basePath: string, milestoneId: string): void {
|
|
131
|
+
try {
|
|
132
|
+
const p = statusPath(basePath, milestoneId);
|
|
133
|
+
if (existsSync(p)) unlinkSync(p);
|
|
134
|
+
} catch { /* non-fatal */ }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── Signal I/O ────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
/** Write a signal file for a worker to consume. */
|
|
140
|
+
export function sendSignal(basePath: string, milestoneId: string, signal: SessionSignal): void {
|
|
141
|
+
try {
|
|
142
|
+
ensureParallelDir(basePath);
|
|
143
|
+
const dest = signalPath(basePath, milestoneId);
|
|
144
|
+
const tmp = dest + TMP_SUFFIX;
|
|
145
|
+
const msg: SignalMessage = { signal, sentAt: Date.now(), from: "coordinator" };
|
|
146
|
+
writeFileSync(tmp, JSON.stringify(msg, null, 2), "utf-8");
|
|
147
|
+
renameSync(tmp, dest);
|
|
148
|
+
} catch { /* non-fatal */ }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Read and delete a signal file (atomic consume). Returns null if no signal pending. */
|
|
152
|
+
export function consumeSignal(basePath: string, milestoneId: string): SignalMessage | null {
|
|
153
|
+
try {
|
|
154
|
+
const p = signalPath(basePath, milestoneId);
|
|
155
|
+
if (!existsSync(p)) return null;
|
|
156
|
+
const raw = readFileSync(p, "utf-8");
|
|
157
|
+
unlinkSync(p);
|
|
158
|
+
return JSON.parse(raw) as SignalMessage;
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ─── Stale Detection ───────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
/** Check whether a session is stale (PID dead or heartbeat timed out). */
|
|
167
|
+
export function isSessionStale(
|
|
168
|
+
status: SessionStatus,
|
|
169
|
+
timeoutMs: number = DEFAULT_STALE_TIMEOUT_MS,
|
|
170
|
+
): boolean {
|
|
171
|
+
if (!isPidAlive(status.pid)) return true;
|
|
172
|
+
const elapsed = Date.now() - status.lastHeartbeat;
|
|
173
|
+
return elapsed > timeoutMs;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Find and remove stale sessions. Returns the milestone IDs that were cleaned up. */
|
|
177
|
+
export function cleanupStaleSessions(
|
|
178
|
+
basePath: string,
|
|
179
|
+
timeoutMs: number = DEFAULT_STALE_TIMEOUT_MS,
|
|
180
|
+
): string[] {
|
|
181
|
+
const removed: string[] = [];
|
|
182
|
+
const statuses = readAllSessionStatuses(basePath);
|
|
183
|
+
|
|
184
|
+
for (const status of statuses) {
|
|
185
|
+
if (isSessionStale(status, timeoutMs)) {
|
|
186
|
+
removeSessionStatus(basePath, status.milestoneId);
|
|
187
|
+
// Also clean up any lingering signal file
|
|
188
|
+
try {
|
|
189
|
+
const sig = signalPath(basePath, status.milestoneId);
|
|
190
|
+
if (existsSync(sig)) unlinkSync(sig);
|
|
191
|
+
} catch { /* non-fatal */ }
|
|
192
|
+
removed.push(status.milestoneId);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return removed;
|
|
197
|
+
}
|
|
@@ -32,7 +32,6 @@ import {
|
|
|
32
32
|
|
|
33
33
|
import { milestoneIdSort, findMilestoneIds } from './guided-flow.js';
|
|
34
34
|
import { nativeBatchParseGsdFiles, type BatchParsedFile } from './native-parser-bridge.js';
|
|
35
|
-
import { isDbAvailable, _getAdapter } from './gsd-db.js';
|
|
36
35
|
|
|
37
36
|
import { join, resolve } from 'path';
|
|
38
37
|
import { debugCount, debugTime } from './debug-logger.js';
|
|
@@ -53,6 +52,19 @@ export function isMilestoneComplete(roadmap: Roadmap): boolean {
|
|
|
53
52
|
return roadmap.slices.length > 0 && roadmap.slices.every(s => s.done);
|
|
54
53
|
}
|
|
55
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Check whether a VALIDATION file's verdict is terminal (pass or needs-attention).
|
|
57
|
+
* A non-terminal verdict (needs-remediation) means validation must re-run
|
|
58
|
+
* after remediation slices are executed.
|
|
59
|
+
*/
|
|
60
|
+
export function isValidationTerminal(validationContent: string): boolean {
|
|
61
|
+
const match = validationContent.match(/^---\n([\s\S]*?)\n---/);
|
|
62
|
+
if (!match) return false;
|
|
63
|
+
const verdict = match[1].match(/verdict:\s*(\S+)/);
|
|
64
|
+
if (!verdict) return false;
|
|
65
|
+
return verdict[1] === 'pass' || verdict[1] === 'needs-attention';
|
|
66
|
+
}
|
|
67
|
+
|
|
56
68
|
// ─── State Derivation ──────────────────────────────────────────────────────
|
|
57
69
|
|
|
58
70
|
// ── deriveState memoization ─────────────────────────────────────────────────
|
|
@@ -82,6 +94,11 @@ export function invalidateStateCache(): void {
|
|
|
82
94
|
*/
|
|
83
95
|
export async function getActiveMilestoneId(basePath: string): Promise<string | null> {
|
|
84
96
|
const milestoneIds = findMilestoneIds(basePath);
|
|
97
|
+
// Parallel worker isolation
|
|
98
|
+
const milestoneLock = process.env.GSD_MILESTONE_LOCK;
|
|
99
|
+
if (milestoneLock) {
|
|
100
|
+
return milestoneIds.includes(milestoneLock) ? milestoneLock : null;
|
|
101
|
+
}
|
|
85
102
|
for (const mid of milestoneIds) {
|
|
86
103
|
const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
|
|
87
104
|
const content = roadmapFile ? await loadFile(roadmapFile) : null;
|
|
@@ -129,6 +146,18 @@ export async function deriveState(basePath: string): Promise<GSDState> {
|
|
|
129
146
|
async function _deriveStateImpl(basePath: string): Promise<GSDState> {
|
|
130
147
|
const milestoneIds = findMilestoneIds(basePath);
|
|
131
148
|
|
|
149
|
+
// ── Parallel worker isolation ──────────────────────────────────────────
|
|
150
|
+
// When GSD_MILESTONE_LOCK is set, this process is a parallel worker
|
|
151
|
+
// scoped to a single milestone. Filter the milestone list so this worker
|
|
152
|
+
// only sees its assigned milestone (all others are treated as if they
|
|
153
|
+
// don't exist). This gives each worker complete isolation without
|
|
154
|
+
// modifying any other state derivation logic.
|
|
155
|
+
const milestoneLock = process.env.GSD_MILESTONE_LOCK;
|
|
156
|
+
if (milestoneLock && milestoneIds.includes(milestoneLock)) {
|
|
157
|
+
milestoneIds.length = 0;
|
|
158
|
+
milestoneIds.push(milestoneLock);
|
|
159
|
+
}
|
|
160
|
+
|
|
132
161
|
// ── Batch-parse file cache ──────────────────────────────────────────────
|
|
133
162
|
// When the native Rust parser is available, read every .md file under .gsd/
|
|
134
163
|
// in one call and build an in-memory content map keyed by absolute path.
|
|
@@ -136,30 +165,12 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
|
|
|
136
165
|
const fileContentCache = new Map<string, string>();
|
|
137
166
|
const gsdDir = gsdRoot(basePath);
|
|
138
167
|
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const adapter = _getAdapter();
|
|
146
|
-
if (adapter) {
|
|
147
|
-
try {
|
|
148
|
-
const rows = adapter.prepare('SELECT path, full_content FROM artifacts').all();
|
|
149
|
-
for (const row of rows) {
|
|
150
|
-
const relPath = (row as Record<string, unknown>)['path'] as string;
|
|
151
|
-
const content = (row as Record<string, unknown>)['full_content'] as string;
|
|
152
|
-
const absPath = resolve(gsdDir, relPath);
|
|
153
|
-
fileContentCache.set(absPath, content);
|
|
154
|
-
}
|
|
155
|
-
dbContentLoaded = rows.length > 0;
|
|
156
|
-
} catch {
|
|
157
|
-
// DB query failed — fall through to native batch parse
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (!dbContentLoaded) {
|
|
168
|
+
// NOTE: We intentionally do NOT load from the SQLite DB here (#759).
|
|
169
|
+
// The DB's artifacts table is populated once during migrateFromMarkdown
|
|
170
|
+
// and is never updated when files change on disk (e.g. roadmap [x] updates,
|
|
171
|
+
// plan checkbox changes). Using stale DB content causes deriveState to
|
|
172
|
+
// return incorrect phase/slice state, leading to infinite skip loops.
|
|
173
|
+
// The native Rust batch parser is fast enough for state derivation.
|
|
163
174
|
const batchFiles = nativeBatchParseGsdFiles(gsdDir);
|
|
164
175
|
if (batchFiles) {
|
|
165
176
|
for (const f of batchFiles) {
|
|
@@ -167,7 +178,6 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
|
|
|
167
178
|
fileContentCache.set(absPath, f.rawContent);
|
|
168
179
|
}
|
|
169
180
|
}
|
|
170
|
-
}
|
|
171
181
|
|
|
172
182
|
/**
|
|
173
183
|
* Load file content from batch cache first, falling back to disk read.
|
|
@@ -279,10 +289,20 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
|
|
|
279
289
|
const complete = isMilestoneComplete(roadmap);
|
|
280
290
|
|
|
281
291
|
if (complete) {
|
|
282
|
-
// All slices done — check
|
|
292
|
+
// All slices done — check validation and summary state
|
|
293
|
+
const validationFile = resolveMilestoneFile(basePath, mid, "VALIDATION");
|
|
294
|
+
const validationContent = validationFile ? await cachedLoadFile(validationFile) : null;
|
|
295
|
+
const validationTerminal = validationContent ? isValidationTerminal(validationContent) : false;
|
|
283
296
|
const summaryFile = resolveMilestoneFile(basePath, mid, "SUMMARY");
|
|
284
|
-
|
|
285
|
-
|
|
297
|
+
|
|
298
|
+
if (!validationTerminal && !activeMilestoneFound) {
|
|
299
|
+
// No terminal validation yet → validating-milestone
|
|
300
|
+
activeMilestone = { id: mid, title };
|
|
301
|
+
activeRoadmap = roadmap;
|
|
302
|
+
activeMilestoneFound = true;
|
|
303
|
+
registry.push({ id: mid, title, status: 'active' });
|
|
304
|
+
} else if (!summaryFile && !activeMilestoneFound) {
|
|
305
|
+
// Validated but no summary written yet → completing-milestone
|
|
286
306
|
activeMilestone = { id: mid, title };
|
|
287
307
|
activeRoadmap = roadmap;
|
|
288
308
|
activeMilestoneFound = true;
|
|
@@ -385,12 +405,34 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
|
|
|
385
405
|
};
|
|
386
406
|
}
|
|
387
407
|
|
|
388
|
-
// Check if active milestone needs completion (all slices done
|
|
408
|
+
// Check if active milestone needs validation or completion (all slices done)
|
|
389
409
|
if (isMilestoneComplete(activeRoadmap)) {
|
|
410
|
+
const validationFile = resolveMilestoneFile(basePath, activeMilestone.id, "VALIDATION");
|
|
411
|
+
const validationContent = validationFile ? await cachedLoadFile(validationFile) : null;
|
|
412
|
+
const validationTerminal = validationContent ? isValidationTerminal(validationContent) : false;
|
|
390
413
|
const sliceProgress = {
|
|
391
414
|
done: activeRoadmap.slices.length,
|
|
392
415
|
total: activeRoadmap.slices.length,
|
|
393
416
|
};
|
|
417
|
+
|
|
418
|
+
if (!validationTerminal) {
|
|
419
|
+
return {
|
|
420
|
+
activeMilestone,
|
|
421
|
+
activeSlice: null,
|
|
422
|
+
activeTask: null,
|
|
423
|
+
phase: 'validating-milestone',
|
|
424
|
+
recentDecisions: [],
|
|
425
|
+
blockers: [],
|
|
426
|
+
nextAction: `Validate milestone ${activeMilestone.id} before completion.`,
|
|
427
|
+
registry,
|
|
428
|
+
requirements,
|
|
429
|
+
progress: {
|
|
430
|
+
milestones: milestoneProgress,
|
|
431
|
+
slices: sliceProgress,
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
394
436
|
return {
|
|
395
437
|
activeMilestone,
|
|
396
438
|
activeSlice: null,
|
|
@@ -27,3 +27,84 @@ test("pauseAutoForProviderError warns and pauses without requiring ctx.log", asy
|
|
|
27
27
|
},
|
|
28
28
|
]);
|
|
29
29
|
});
|
|
30
|
+
|
|
31
|
+
test("pauseAutoForProviderError schedules auto-resume for rate limit errors", async () => {
|
|
32
|
+
const notifications: Array<{ message: string; level: string }> = [];
|
|
33
|
+
let pauseCalls = 0;
|
|
34
|
+
let resumeCalled = false;
|
|
35
|
+
|
|
36
|
+
// Use fake timer
|
|
37
|
+
const originalSetTimeout = globalThis.setTimeout;
|
|
38
|
+
const timers: Array<{ fn: () => void; delay: number }> = [];
|
|
39
|
+
globalThis.setTimeout = ((fn: () => void, delay: number) => {
|
|
40
|
+
timers.push({ fn, delay });
|
|
41
|
+
return 0 as unknown as ReturnType<typeof setTimeout>;
|
|
42
|
+
}) as typeof setTimeout;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
await pauseAutoForProviderError(
|
|
46
|
+
{
|
|
47
|
+
notify(message, level?) {
|
|
48
|
+
notifications.push({ message, level: level ?? "info" });
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
": rate limit exceeded",
|
|
52
|
+
async () => {
|
|
53
|
+
pauseCalls += 1;
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
isRateLimit: true,
|
|
57
|
+
retryAfterMs: 90000,
|
|
58
|
+
resume: () => {
|
|
59
|
+
resumeCalled = true;
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
assert.equal(pauseCalls, 1, "should pause auto-mode");
|
|
65
|
+
assert.equal(timers.length, 1, "should schedule one timer");
|
|
66
|
+
assert.equal(timers[0].delay, 90000, "timer should match retryAfterMs");
|
|
67
|
+
assert.deepEqual(notifications[0], {
|
|
68
|
+
message: "Rate limited: rate limit exceeded. Auto-resuming in 90s...",
|
|
69
|
+
level: "warning",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Fire the timer
|
|
73
|
+
timers[0].fn();
|
|
74
|
+
assert.equal(resumeCalled, true, "should call resume after timer fires");
|
|
75
|
+
assert.deepEqual(notifications[1], {
|
|
76
|
+
message: "Rate limit window elapsed. Resuming auto-mode.",
|
|
77
|
+
level: "info",
|
|
78
|
+
});
|
|
79
|
+
} finally {
|
|
80
|
+
globalThis.setTimeout = originalSetTimeout;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("pauseAutoForProviderError falls back to indefinite pause when not rate limit", async () => {
|
|
85
|
+
const notifications: Array<{ message: string; level: string }> = [];
|
|
86
|
+
let pauseCalls = 0;
|
|
87
|
+
|
|
88
|
+
await pauseAutoForProviderError(
|
|
89
|
+
{
|
|
90
|
+
notify(message, level?) {
|
|
91
|
+
notifications.push({ message, level: level ?? "info" });
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
": connection refused",
|
|
95
|
+
async () => {
|
|
96
|
+
pauseCalls += 1;
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
isRateLimit: false,
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
assert.equal(pauseCalls, 1);
|
|
104
|
+
assert.deepEqual(notifications, [
|
|
105
|
+
{
|
|
106
|
+
message: "Auto-mode paused due to provider error: connection refused",
|
|
107
|
+
level: "warning",
|
|
108
|
+
},
|
|
109
|
+
]);
|
|
110
|
+
});
|
|
@@ -9,8 +9,12 @@ import {
|
|
|
9
9
|
|
|
10
10
|
test("getBudgetAlertLevel returns the expected threshold bucket", () => {
|
|
11
11
|
assert.equal(getBudgetAlertLevel(0.10), 0);
|
|
12
|
+
assert.equal(getBudgetAlertLevel(0.74), 0);
|
|
12
13
|
assert.equal(getBudgetAlertLevel(0.75), 75);
|
|
13
|
-
assert.equal(getBudgetAlertLevel(0.
|
|
14
|
+
assert.equal(getBudgetAlertLevel(0.79), 75);
|
|
15
|
+
assert.equal(getBudgetAlertLevel(0.80), 80);
|
|
16
|
+
assert.equal(getBudgetAlertLevel(0.85), 80);
|
|
17
|
+
assert.equal(getBudgetAlertLevel(0.89), 80);
|
|
14
18
|
assert.equal(getBudgetAlertLevel(0.90), 90);
|
|
15
19
|
assert.equal(getBudgetAlertLevel(1.00), 100);
|
|
16
20
|
});
|
|
@@ -18,14 +22,27 @@ test("getBudgetAlertLevel returns the expected threshold bucket", () => {
|
|
|
18
22
|
test("getNewBudgetAlertLevel only emits once per threshold", () => {
|
|
19
23
|
assert.equal(getNewBudgetAlertLevel(0, 0.74), null);
|
|
20
24
|
assert.equal(getNewBudgetAlertLevel(0, 0.75), 75);
|
|
21
|
-
assert.equal(getNewBudgetAlertLevel(75, 0.
|
|
22
|
-
assert.equal(getNewBudgetAlertLevel(75, 0.
|
|
25
|
+
assert.equal(getNewBudgetAlertLevel(75, 0.79), null);
|
|
26
|
+
assert.equal(getNewBudgetAlertLevel(75, 0.80), 80);
|
|
27
|
+
assert.equal(getNewBudgetAlertLevel(80, 0.85), null);
|
|
28
|
+
assert.equal(getNewBudgetAlertLevel(80, 0.90), 90);
|
|
23
29
|
assert.equal(getNewBudgetAlertLevel(90, 0.95), null);
|
|
24
30
|
assert.equal(getNewBudgetAlertLevel(90, 1.0), 100);
|
|
25
31
|
assert.equal(getNewBudgetAlertLevel(100, 1.2), null);
|
|
26
32
|
});
|
|
27
33
|
|
|
34
|
+
test("80% alert fires exactly once between 75% and 90%", () => {
|
|
35
|
+
// Transition from 75 → 80 emits 80
|
|
36
|
+
assert.equal(getNewBudgetAlertLevel(75, 0.80), 80);
|
|
37
|
+
// Already at 80 — no re-emission
|
|
38
|
+
assert.equal(getNewBudgetAlertLevel(80, 0.82), null);
|
|
39
|
+
assert.equal(getNewBudgetAlertLevel(80, 0.89), null);
|
|
40
|
+
// Transition from 80 → 90 emits 90
|
|
41
|
+
assert.equal(getNewBudgetAlertLevel(80, 0.90), 90);
|
|
42
|
+
});
|
|
43
|
+
|
|
28
44
|
test("getBudgetEnforcementAction maps the configured ceiling behavior", () => {
|
|
45
|
+
assert.equal(getBudgetEnforcementAction("warn", 0.80), "none");
|
|
29
46
|
assert.equal(getBudgetEnforcementAction("warn", 0.99), "none");
|
|
30
47
|
assert.equal(getBudgetEnforcementAction("warn", 1.0), "warn");
|
|
31
48
|
assert.equal(getBudgetEnforcementAction("pause", 1.0), "pause");
|