@opengsd/gsd-pi 1.1.1-dev.b2556262 → 1.2.0-dev.844675c9
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/project-sessions.js +4 -2
- package/dist/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/claude-code-cli/stream-adapter.js +17 -9
- package/dist/resources/extensions/gsd/auto/contracts.js +8 -1
- package/dist/resources/extensions/gsd/auto/orchestrator.js +659 -57
- package/dist/resources/extensions/gsd/auto-prompts.js +110 -1
- package/dist/resources/extensions/gsd/auto-runtime-state.js +3 -0
- package/dist/resources/extensions/gsd/auto-tool-tracking.js +5 -0
- package/dist/resources/extensions/gsd/auto-unit-tool-scope.js +29 -0
- package/dist/resources/extensions/gsd/auto-worktree.js +24 -17
- package/dist/resources/extensions/gsd/auto.js +62 -464
- package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +4 -1
- package/dist/resources/extensions/gsd/debug-logger.js +10 -0
- package/dist/resources/extensions/gsd/doctor-proactive.js +7 -2
- package/dist/resources/extensions/gsd/guided-flow.js +2 -2
- package/dist/resources/extensions/gsd/markdown-renderer.js +31 -32
- package/dist/resources/extensions/gsd/mcp-filter.js +6 -0
- package/dist/resources/extensions/gsd/native-git-bridge.js +45 -0
- package/dist/resources/extensions/gsd/prompts/discuss.md +6 -7
- package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +5 -7
- package/dist/resources/extensions/gsd/prompts/guided-discuss-project.md +3 -5
- package/dist/resources/extensions/gsd/prompts/guided-discuss-requirements.md +1 -2
- package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +5 -6
- package/dist/resources/extensions/gsd/prompts/plan-slice.md +1 -1
- package/dist/resources/extensions/gsd/prompts/research-milestone.md +2 -2
- package/dist/resources/extensions/gsd/prompts/validate-milestone.md +5 -3
- package/dist/resources/extensions/gsd/schemas/parsers.js +6 -1
- package/dist/resources/extensions/gsd/state-reconciliation/drift/artifact-db.js +21 -1
- package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +169 -20
- package/dist/resources/extensions/gsd/user-input-boundary.js +42 -4
- package/dist/tsconfig.extensions.tsbuildinfo +1 -0
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +8 -8
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/api/boot/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/bridge-terminal/input/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/bridge-terminal/resize/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/bridge-terminal/stream/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/captures/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/cleanup/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/doctor/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/export-data/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/files/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/forensics/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/git/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/git/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/history/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/hooks/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/inspect/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/knowledge/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/live-state/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/mcp-connections/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/notifications/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/onboarding/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/projects/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/recovery/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/session/browser/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/session/command/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/session/events/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/session/manage/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/settings-data/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/shutdown/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/skill-health/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/steer/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/switch-root/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/terminal/sessions/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/terminal/stream/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/undo/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/api/visualizer/route.js.nft.json +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +8 -8
- package/dist/web/standalone/.next/server/chunks/5047.js +2 -0
- package/dist/web/standalone/.next/server/chunks/5124.js +1 -0
- package/dist/web/standalone/.next/server/chunks/8357.js +2 -2
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/dist/web/standalone/node_modules/@gsd/native/package.json +1 -1
- package/dist/web/standalone/node_modules/node-pty/build/Makefile +1 -1
- package/dist/web/standalone/node_modules/postcss/lib/container.js +26 -18
- package/dist/web/standalone/node_modules/postcss/lib/css-syntax-error.js +47 -14
- package/dist/web/standalone/node_modules/postcss/lib/declaration.js +4 -4
- package/dist/web/standalone/node_modules/postcss/lib/fromJSON.js +3 -3
- package/dist/web/standalone/node_modules/postcss/lib/input.js +54 -29
- package/dist/web/standalone/node_modules/postcss/lib/lazy-result.js +47 -37
- package/dist/web/standalone/node_modules/postcss/lib/map-generator.js +26 -9
- package/dist/web/standalone/node_modules/postcss/lib/no-work-result.js +57 -55
- package/dist/web/standalone/node_modules/postcss/lib/node.js +99 -31
- package/dist/web/standalone/node_modules/postcss/lib/parse.js +1 -1
- package/dist/web/standalone/node_modules/postcss/lib/parser.js +10 -9
- package/dist/web/standalone/node_modules/postcss/lib/postcss.js +12 -12
- package/dist/web/standalone/node_modules/postcss/lib/previous-map.js +30 -11
- package/dist/web/standalone/node_modules/postcss/lib/processor.js +7 -7
- package/dist/web/standalone/node_modules/postcss/lib/result.js +5 -5
- package/dist/web/standalone/node_modules/postcss/lib/rule.js +6 -6
- package/dist/web/standalone/node_modules/postcss/lib/stringifier.js +69 -28
- package/dist/web/standalone/node_modules/postcss/lib/tokenize.js +6 -2
- package/dist/web/standalone/node_modules/postcss/package.json +48 -48
- package/package.json +16 -11
- package/packages/cloud-mcp-gateway/package.json +2 -2
- package/packages/contracts/package.json +1 -1
- package/packages/daemon/package.json +4 -4
- package/packages/gsd-agent-core/package.json +5 -5
- package/packages/gsd-agent-modes/dist/modes/interactive/components/transcript-design.js +1 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/components/transcript-design.js.map +1 -1
- package/packages/gsd-agent-modes/package.json +7 -7
- package/packages/mcp-server/package.json +3 -3
- package/packages/native/package.json +1 -1
- package/packages/pi-agent-core/package.json +1 -1
- package/packages/pi-ai/dist/models.generated.d.ts +0 -34
- package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
- package/packages/pi-ai/dist/models.generated.js +12 -46
- package/packages/pi-ai/dist/models.generated.js.map +1 -1
- package/packages/pi-ai/package.json +1 -1
- package/packages/pi-coding-agent/dist/core/auth-storage.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/auth-storage.js +11 -3
- package/packages/pi-coding-agent/dist/core/auth-storage.js.map +1 -1
- package/packages/pi-coding-agent/package.json +7 -7
- package/packages/pi-tui/package.json +2 -2
- package/packages/rpc-client/package.json +2 -2
- package/pkg/package.json +1 -1
- package/scripts/install/deps.js +10 -0
- package/scripts/link-workspace-packages.cjs +7 -40
- package/src/resources/extensions/claude-code-cli/stream-adapter.ts +18 -8
- package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +2 -2
- package/src/resources/extensions/gsd/auto/contracts.ts +8 -119
- package/src/resources/extensions/gsd/auto/orchestrator.ts +794 -58
- package/src/resources/extensions/gsd/auto-prompts.ts +114 -1
- package/src/resources/extensions/gsd/auto-runtime-state.ts +4 -0
- package/src/resources/extensions/gsd/auto-tool-tracking.ts +5 -0
- package/src/resources/extensions/gsd/auto-unit-tool-scope.ts +33 -0
- package/src/resources/extensions/gsd/auto-worktree.ts +24 -16
- package/src/resources/extensions/gsd/auto.ts +81 -500
- package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +4 -0
- package/src/resources/extensions/gsd/debug-logger.ts +11 -0
- package/src/resources/extensions/gsd/doctor-proactive.ts +8 -2
- package/src/resources/extensions/gsd/guided-flow.ts +2 -2
- package/src/resources/extensions/gsd/markdown-renderer.ts +38 -19
- package/src/resources/extensions/gsd/mcp-filter.ts +7 -0
- package/src/resources/extensions/gsd/native-git-bridge.ts +48 -0
- package/src/resources/extensions/gsd/prompts/discuss.md +6 -7
- package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +5 -7
- package/src/resources/extensions/gsd/prompts/guided-discuss-project.md +3 -5
- package/src/resources/extensions/gsd/prompts/guided-discuss-requirements.md +1 -2
- package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +5 -6
- package/src/resources/extensions/gsd/prompts/plan-slice.md +1 -1
- package/src/resources/extensions/gsd/prompts/research-milestone.md +2 -2
- package/src/resources/extensions/gsd/prompts/validate-milestone.md +5 -3
- package/src/resources/extensions/gsd/schemas/parsers.ts +6 -1
- package/src/resources/extensions/gsd/state-reconciliation/drift/artifact-db.ts +31 -10
- package/src/resources/extensions/gsd/tests/artifact-db-drift-memo.test.ts +66 -0
- package/src/resources/extensions/gsd/tests/auto-dispatch-baseline-harness.test.ts +53 -0
- package/src/resources/extensions/gsd/tests/auto-orchestrator.test.ts +590 -855
- package/src/resources/extensions/gsd/tests/auto-paused-ui-cleanup.test.ts +38 -10
- package/src/resources/extensions/gsd/tests/debug-logger.test.ts +15 -0
- package/src/resources/extensions/gsd/tests/execute-summary-save-empty-project.test.ts +64 -1
- package/src/resources/extensions/gsd/tests/integration/merge-strategy-regular.test.ts +157 -0
- package/src/resources/extensions/gsd/tests/markdown-renderer-parse-cache.test.ts +75 -0
- package/src/resources/extensions/gsd/tests/native-merge-regular.test.ts +139 -0
- package/src/resources/extensions/gsd/tests/orchestrator-legacy-parity.test.ts +127 -0
- package/src/resources/extensions/gsd/tests/parse-project-milestone-bridge.test.ts +77 -0
- package/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +4 -2
- package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +29 -2
- package/src/resources/extensions/gsd/tests/research-milestone-composer.test.ts +65 -0
- package/src/resources/extensions/gsd/tests/start-auto-detached.test.ts +19 -5
- package/src/resources/extensions/gsd/tests/token-tool-gating.test.ts +38 -0
- package/src/resources/extensions/gsd/tests/user-input-boundary.test.ts +62 -0
- package/src/resources/extensions/gsd/tests/worktree-safety.test.ts +24 -0
- package/src/resources/extensions/gsd/tests/write-gate-planning-unit.test.ts +15 -3
- package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +183 -21
- package/src/resources/extensions/gsd/user-input-boundary.ts +37 -5
- package/dist/web/standalone/.next/server/chunks/678.js +0 -2
- package/packages/gsd-agent-modes/dist/modes/interactive/components/__prototype__/gsd-widget-prototype.d.ts +0 -21
- package/packages/gsd-agent-modes/dist/modes/interactive/components/__prototype__/gsd-widget-prototype.d.ts.map +0 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/components/__prototype__/gsd-widget-prototype.js +0 -213
- package/packages/gsd-agent-modes/dist/modes/interactive/components/__prototype__/gsd-widget-prototype.js.map +0 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/components/__prototype__/transcript-density-prototype.d.ts +0 -28
- package/packages/gsd-agent-modes/dist/modes/interactive/components/__prototype__/transcript-density-prototype.d.ts.map +0 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/components/__prototype__/transcript-density-prototype.js +0 -249
- package/packages/gsd-agent-modes/dist/modes/interactive/components/__prototype__/transcript-density-prototype.js.map +0 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/components/__prototype__/transcript-design-prototype.d.ts +0 -19
- package/packages/gsd-agent-modes/dist/modes/interactive/components/__prototype__/transcript-design-prototype.d.ts.map +0 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/components/__prototype__/transcript-design-prototype.js +0 -797
- package/packages/gsd-agent-modes/dist/modes/interactive/components/__prototype__/transcript-design-prototype.js.map +0 -1
- package/scripts/ensure-workspace-builds.cjs +0 -129
- /package/dist/web/standalone/.next/static/{tJOKQbQRO-9MiFDO8DIDS → Qbr81pQ-pbQXP4bq2VXLv}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{tJOKQbQRO-9MiFDO8DIDS → Qbr81pQ-pbQXP4bq2VXLv}/_ssgManifest.js +0 -0
|
@@ -1,26 +1,201 @@
|
|
|
1
1
|
// Project/App: gsd-pi
|
|
2
2
|
// File Purpose: Auto Orchestration module contract and ADR-015 invariant sequence tests.
|
|
3
|
+
//
|
|
4
|
+
// Phase 2 of #442 collapsed the nine adapter seams into AutoOrchestrator. These
|
|
5
|
+
// tests therefore drive the REAL collapsed orchestrator against real temp
|
|
6
|
+
// SQLite + git fixtures (fixture builder modelled on
|
|
7
|
+
// state-reconciliation-drift.test.ts) and inject dispatch decisions through the
|
|
8
|
+
// real unified rule registry (setRegistry) rather than mock adapters. Decision
|
|
9
|
+
// logic is asserted on observable advance() outcomes and journal events instead
|
|
10
|
+
// of an internal calls[] array. Dispatch-decision parity (formerly the
|
|
11
|
+
// createWiredDispatchAdapter tests) is asserted against the exported pure
|
|
12
|
+
// decideOrchestratorDispatch helper.
|
|
3
13
|
|
|
4
14
|
import test from "node:test";
|
|
5
15
|
import assert from "node:assert/strict";
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
6
17
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
7
18
|
import { tmpdir } from "node:os";
|
|
8
19
|
import { join } from "node:path";
|
|
9
20
|
|
|
10
|
-
import {
|
|
11
|
-
|
|
21
|
+
import {
|
|
22
|
+
createAutoOrchestrator,
|
|
23
|
+
decideOrchestratorDispatch,
|
|
24
|
+
resolveLiveOrchestratorBasePath,
|
|
25
|
+
STUCK_WINDOW_SIZE,
|
|
26
|
+
} from "../auto/orchestrator.js";
|
|
27
|
+
import type { OrchestratorContext } from "../auto/orchestrator.js";
|
|
28
|
+
import type { AutoOrchestrationModule, AutoSessionContext } from "../auto/contracts.js";
|
|
12
29
|
import type { GSDState } from "../types.js";
|
|
13
|
-
import { createWiredDispatchAdapter, resolveLiveOrchestratorBasePath } from "../auto.js";
|
|
14
30
|
import { resolveDispatch, type DispatchContext } from "../auto-dispatch.js";
|
|
15
31
|
import { RuleRegistry, setRegistry, resetRegistry } from "../rule-registry.js";
|
|
16
32
|
import type { UnifiedRule } from "../rule-types.js";
|
|
17
33
|
import { supportsStructuredQuestions } from "../workflow-mcp.js";
|
|
18
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
closeDatabase,
|
|
36
|
+
insertMilestone,
|
|
37
|
+
insertSlice,
|
|
38
|
+
insertTask,
|
|
39
|
+
openDatabase,
|
|
40
|
+
} from "../gsd-db.js";
|
|
41
|
+
import { AutoSession } from "../auto/session.js";
|
|
42
|
+
import { acquireSessionLock, releaseSessionLock } from "../session-lock.js";
|
|
43
|
+
import { queryJournal } from "../journal.js";
|
|
44
|
+
import { invalidateAllCaches } from "../cache.js";
|
|
45
|
+
import { invalidateStateCache } from "../state.js";
|
|
46
|
+
|
|
47
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
48
|
+
// Fixture builder
|
|
49
|
+
//
|
|
50
|
+
// Builds a real, isolated project: a git repo (so the pre-dispatch health gate
|
|
51
|
+
// and merge-state reconciliation have something real to probe), a SQLite DB
|
|
52
|
+
// seeded with one active milestone/slice/task, and the matching ROADMAP/PLAN
|
|
53
|
+
// markdown projection. A real session lock is acquired so the orchestrator's
|
|
54
|
+
// ensureLockOwnership passes. A fresh AutoSession is wired to the base path. A
|
|
55
|
+
// dispatch rule is installed in the real unified registry so resolveDispatch
|
|
56
|
+
// yields a deterministic decision — this is the only "injection", and it is the
|
|
57
|
+
// same public seam (setRegistry) the dispatch engine already exposes.
|
|
58
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
type DispatchRuleResult =
|
|
61
|
+
| { action: "dispatch"; unitType: string; unitId: string; prompt: string; pauseAfterDispatch?: boolean }
|
|
62
|
+
| { action: "stop"; reason: string; level: "info" | "warning" | "error" }
|
|
63
|
+
| { action: "skip"; matchedRule?: string };
|
|
64
|
+
|
|
65
|
+
interface FixtureOptions {
|
|
66
|
+
/** When provided, the rule returns this result. Defaults to dispatching M001/S01/T01. */
|
|
67
|
+
dispatch?: () => DispatchRuleResult | Promise<DispatchRuleResult>;
|
|
68
|
+
/** Rule name (becomes the dispatch `reason`/`matchedRule`). */
|
|
69
|
+
ruleName?: string;
|
|
70
|
+
/** Skip seeding a ready task (used for the "no remaining units" / complete scenarios). */
|
|
71
|
+
noTask?: boolean;
|
|
72
|
+
/** Mark the seeded milestone complete (drives the completion → stopped path). */
|
|
73
|
+
complete?: boolean;
|
|
74
|
+
}
|
|
19
75
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
76
|
+
interface Fixture {
|
|
77
|
+
base: string;
|
|
78
|
+
session: AutoSession;
|
|
79
|
+
ctx: OrchestratorContext;
|
|
80
|
+
orchestrator: AutoOrchestrationModule;
|
|
81
|
+
/** Names emitted to the journal by the orchestrator (data.name), in order. */
|
|
82
|
+
journalNames(): string[];
|
|
83
|
+
cleanup(): void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const DEFAULT_DISPATCH: DispatchRuleResult = {
|
|
87
|
+
action: "dispatch",
|
|
88
|
+
unitType: "execute-task",
|
|
89
|
+
unitId: "M001/S01/T01",
|
|
90
|
+
prompt: "fixture-prompt",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function gitInit(base: string): void {
|
|
94
|
+
execFileSync("git", ["init", "--initial-branch=main"], { cwd: base, stdio: "ignore" });
|
|
95
|
+
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: base, stdio: "ignore" });
|
|
96
|
+
execFileSync("git", ["config", "user.name", "Test"], { cwd: base, stdio: "ignore" });
|
|
97
|
+
writeFileSync(join(base, ".gitkeep"), "");
|
|
98
|
+
execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" });
|
|
99
|
+
execFileSync("git", ["commit", "-m", "initial"], { cwd: base, stdio: "ignore" });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function makeFixture(opts: FixtureOptions = {}): Fixture {
|
|
103
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-orchestrator-"));
|
|
104
|
+
gitInit(base);
|
|
105
|
+
|
|
106
|
+
const milestoneDir = join(base, ".gsd", "milestones", "M001");
|
|
107
|
+
const sliceDir = join(milestoneDir, "slices", "S01");
|
|
108
|
+
mkdirSync(join(sliceDir, "tasks"), { recursive: true });
|
|
109
|
+
|
|
110
|
+
invalidateAllCaches();
|
|
111
|
+
invalidateStateCache();
|
|
112
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
113
|
+
insertMilestone({ id: "M001", title: "Milestone", status: opts.complete ? "complete" : "active" });
|
|
114
|
+
if (!opts.noTask && !opts.complete) {
|
|
115
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "Slice", status: "active", risk: "low", depends: [], demo: "", sequence: 1 });
|
|
116
|
+
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", title: "Task", status: "active" });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
writeFileSync(
|
|
120
|
+
join(milestoneDir, "M001-ROADMAP.md"),
|
|
121
|
+
[
|
|
122
|
+
"# M001: Milestone",
|
|
123
|
+
"",
|
|
124
|
+
"**Vision:** Fixture milestone",
|
|
125
|
+
"",
|
|
126
|
+
"## Slices",
|
|
127
|
+
"",
|
|
128
|
+
"- [ ] **S01: Slice** `risk:low` `depends:[]`",
|
|
129
|
+
"",
|
|
130
|
+
].join("\n"),
|
|
131
|
+
);
|
|
132
|
+
if (!opts.noTask && !opts.complete) {
|
|
133
|
+
writeFileSync(
|
|
134
|
+
join(sliceDir, "S01-PLAN.md"),
|
|
135
|
+
[
|
|
136
|
+
"# S01: Slice",
|
|
137
|
+
"",
|
|
138
|
+
"**Goal:** Fixture goal",
|
|
139
|
+
"**Demo:** Fixture demo",
|
|
140
|
+
"",
|
|
141
|
+
"## Must-Haves",
|
|
142
|
+
"",
|
|
143
|
+
"- Everything works",
|
|
144
|
+
"",
|
|
145
|
+
"## Tasks",
|
|
146
|
+
"",
|
|
147
|
+
"- [ ] **T01: Task** `est:1h`",
|
|
148
|
+
"",
|
|
149
|
+
].join("\n"),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
acquireSessionLock(base);
|
|
154
|
+
|
|
155
|
+
const session = new AutoSession();
|
|
156
|
+
session.basePath = base;
|
|
157
|
+
session.originalBasePath = base;
|
|
158
|
+
session.currentMilestoneId = "M001";
|
|
159
|
+
session.resourceVersionOnStart = null;
|
|
160
|
+
|
|
161
|
+
const ctx: OrchestratorContext = {
|
|
162
|
+
ctx: { model: {}, modelRegistry: { getAll: () => [] }, ui: { notify() {} } } as never,
|
|
163
|
+
pi: { getActiveTools: () => [] } as never,
|
|
164
|
+
dispatchBasePath: base,
|
|
165
|
+
runtimeBasePath: base,
|
|
166
|
+
session,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const ruleName = opts.ruleName ?? "fixture-dispatch";
|
|
170
|
+
const decide = opts.dispatch ?? (() => DEFAULT_DISPATCH);
|
|
171
|
+
const rule: UnifiedRule = {
|
|
172
|
+
name: ruleName,
|
|
173
|
+
when: "dispatch",
|
|
174
|
+
evaluation: "first-match",
|
|
175
|
+
where: async () => decide(),
|
|
176
|
+
then: (r: unknown) => r,
|
|
177
|
+
};
|
|
178
|
+
setRegistry(new RuleRegistry([rule]));
|
|
179
|
+
|
|
180
|
+
const orchestrator = createAutoOrchestrator(ctx);
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
base,
|
|
184
|
+
session,
|
|
185
|
+
ctx,
|
|
186
|
+
orchestrator,
|
|
187
|
+
journalNames() {
|
|
188
|
+
return queryJournal(base)
|
|
189
|
+
.map((e) => (e.data as Record<string, unknown> | undefined)?.name)
|
|
190
|
+
.filter((n): n is string => typeof n === "string");
|
|
191
|
+
},
|
|
192
|
+
cleanup() {
|
|
193
|
+
resetRegistry();
|
|
194
|
+
try { releaseSessionLock(base); } catch { /* */ }
|
|
195
|
+
try { closeDatabase(); } catch { /* */ }
|
|
196
|
+
try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
|
|
197
|
+
},
|
|
198
|
+
};
|
|
24
199
|
}
|
|
25
200
|
|
|
26
201
|
function makeState(): GSDState {
|
|
@@ -38,637 +213,370 @@ function makeState(): GSDState {
|
|
|
38
213
|
};
|
|
39
214
|
}
|
|
40
215
|
|
|
41
|
-
|
|
42
|
-
const calls: string[] = [];
|
|
43
|
-
const stateSnapshot = makeState();
|
|
44
|
-
|
|
45
|
-
const deps: AutoOrchestratorDeps = {
|
|
46
|
-
stateReconciliation: {
|
|
47
|
-
async reconcileBeforeDispatch() {
|
|
48
|
-
calls.push("state.reconcile");
|
|
49
|
-
return { ok: true, stateSnapshot };
|
|
50
|
-
},
|
|
51
|
-
},
|
|
52
|
-
dispatch: {
|
|
53
|
-
async decideNextUnit(input) {
|
|
54
|
-
calls.push("dispatch.decide");
|
|
55
|
-
assert.equal(input.stateSnapshot, stateSnapshot);
|
|
56
|
-
return { unitType: "execute-task", unitId: "T01", reason: "ready", preconditions: [] };
|
|
57
|
-
},
|
|
58
|
-
},
|
|
59
|
-
toolContract: {
|
|
60
|
-
async compileUnitToolContract() {
|
|
61
|
-
calls.push("tool.compile");
|
|
62
|
-
return { ok: true };
|
|
63
|
-
},
|
|
64
|
-
},
|
|
65
|
-
recovery: {
|
|
66
|
-
async classifyAndRecover() {
|
|
67
|
-
calls.push("recovery.classify");
|
|
68
|
-
return { action: "stop", reason: "fatal" };
|
|
69
|
-
},
|
|
70
|
-
},
|
|
71
|
-
worktree: {
|
|
72
|
-
async prepareForUnit() {
|
|
73
|
-
calls.push("worktree.prepare");
|
|
74
|
-
return { ok: true };
|
|
75
|
-
},
|
|
76
|
-
async syncAfterUnit() { calls.push("worktree.sync"); },
|
|
77
|
-
async cleanupOnStop() { calls.push("worktree.cleanup"); },
|
|
78
|
-
},
|
|
79
|
-
health: {
|
|
80
|
-
checkResourcesStale() {
|
|
81
|
-
calls.push("health.stale");
|
|
82
|
-
return null;
|
|
83
|
-
},
|
|
84
|
-
async preAdvanceGate() {
|
|
85
|
-
calls.push("health.pre");
|
|
86
|
-
return { kind: "pass" };
|
|
87
|
-
},
|
|
88
|
-
async postAdvanceRecord() { calls.push("health.post"); },
|
|
89
|
-
},
|
|
90
|
-
runtime: {
|
|
91
|
-
async ensureLockOwnership() { calls.push("runtime.lock"); },
|
|
92
|
-
async journalTransition(event) { calls.push(`journal:${event.name}`); },
|
|
93
|
-
},
|
|
94
|
-
notifications: {
|
|
95
|
-
async notifyLifecycle(event) { calls.push(`notify:${event.name}`); },
|
|
96
|
-
},
|
|
97
|
-
uokGate: {
|
|
98
|
-
async emit(input) { calls.push(`gate:${input.gateId}:${input.outcome}`); },
|
|
99
|
-
},
|
|
100
|
-
};
|
|
216
|
+
const SESSION_CONTEXT: AutoSessionContext = { basePath: "/tmp/project", trigger: "manual" };
|
|
101
217
|
|
|
102
|
-
|
|
103
|
-
|
|
218
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
219
|
+
// Lifecycle: start / resume / stop
|
|
220
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
104
221
|
|
|
105
|
-
test("start() enters running phase without dispatching", async () => {
|
|
106
|
-
const
|
|
107
|
-
|
|
222
|
+
test("start() enters running phase without dispatching", async (t) => {
|
|
223
|
+
const f = makeFixture();
|
|
224
|
+
t.after(() => f.cleanup());
|
|
108
225
|
|
|
109
|
-
const result = await orchestrator.start(
|
|
226
|
+
const result = await f.orchestrator.start(SESSION_CONTEXT);
|
|
110
227
|
|
|
111
228
|
assert.equal(result.kind, "started");
|
|
112
|
-
const status = orchestrator.getStatus();
|
|
229
|
+
const status = f.orchestrator.getStatus();
|
|
113
230
|
assert.equal(status.phase, "running");
|
|
114
231
|
assert.equal(status.activeUnit, undefined);
|
|
115
|
-
assert.ok(
|
|
116
|
-
assert.ok(!
|
|
232
|
+
assert.ok(f.journalNames().includes("start"));
|
|
233
|
+
assert.ok(!f.journalNames().includes("advance"));
|
|
117
234
|
});
|
|
118
235
|
|
|
119
|
-
test("
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
checkResourcesStale: () => null,
|
|
123
|
-
async preAdvanceGate() { return { kind: "fail", reason: "doctor-block" }; },
|
|
124
|
-
async postAdvanceRecord() {},
|
|
125
|
-
},
|
|
126
|
-
});
|
|
127
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
236
|
+
test("resume() enters running phase without dispatching", async (t) => {
|
|
237
|
+
const f = makeFixture();
|
|
238
|
+
t.after(() => f.cleanup());
|
|
128
239
|
|
|
129
|
-
const result = await orchestrator.
|
|
240
|
+
const result = await f.orchestrator.resume();
|
|
130
241
|
|
|
131
|
-
|
|
132
|
-
assert.equal(
|
|
133
|
-
assert.
|
|
134
|
-
assert.ok(calls.includes("gate:pre-dispatch-health-gate:manual-attention"));
|
|
242
|
+
assert.equal(result.kind, "resumed");
|
|
243
|
+
assert.equal(f.orchestrator.getStatus().phase, "running");
|
|
244
|
+
assert.ok(!f.journalNames().includes("advance"));
|
|
135
245
|
});
|
|
136
246
|
|
|
137
|
-
test("
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
checkResourcesStale: () => null,
|
|
141
|
-
async preAdvanceGate() {
|
|
142
|
-
return { kind: "fail", reason: "Could not verify git conflict state", action: "stop" };
|
|
143
|
-
},
|
|
144
|
-
async postAdvanceRecord() {},
|
|
145
|
-
},
|
|
146
|
-
});
|
|
147
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
247
|
+
test("transitionCount increases across lifecycle transitions", async (t) => {
|
|
248
|
+
const f = makeFixture();
|
|
249
|
+
t.after(() => f.cleanup());
|
|
148
250
|
|
|
149
|
-
const
|
|
251
|
+
const before = f.orchestrator.getStatus().transitionCount;
|
|
252
|
+
await f.orchestrator.start(SESSION_CONTEXT);
|
|
253
|
+
const afterStart = f.orchestrator.getStatus().transitionCount;
|
|
254
|
+
await f.orchestrator.stop("done");
|
|
255
|
+
const afterStop = f.orchestrator.getStatus().transitionCount;
|
|
150
256
|
|
|
151
|
-
|
|
152
|
-
assert.
|
|
257
|
+
assert.ok(afterStart > before);
|
|
258
|
+
assert.ok(afterStop > afterStart);
|
|
153
259
|
});
|
|
154
260
|
|
|
155
|
-
test("
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
checkResourcesStale: () => "resources changed since session start",
|
|
159
|
-
async preAdvanceGate() { return { kind: "pass" }; },
|
|
160
|
-
async postAdvanceRecord() {},
|
|
161
|
-
},
|
|
162
|
-
});
|
|
163
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
261
|
+
test("stop() transitions to stopped and journals stop", async (t) => {
|
|
262
|
+
const f = makeFixture();
|
|
263
|
+
t.after(() => f.cleanup());
|
|
164
264
|
|
|
165
|
-
const result = await orchestrator.
|
|
265
|
+
const result = await f.orchestrator.stop("user-request");
|
|
166
266
|
|
|
167
|
-
|
|
168
|
-
assert.equal(
|
|
169
|
-
assert.
|
|
170
|
-
assert.ok(calls.includes("gate:resource-version-guard:fail"));
|
|
171
|
-
assert.ok(!calls.includes("health.pre"));
|
|
172
|
-
assert.ok(!calls.includes("state.reconcile"));
|
|
267
|
+
assert.equal(result.kind, "stopped");
|
|
268
|
+
assert.equal(f.orchestrator.getStatus().phase, "stopped");
|
|
269
|
+
assert.ok(f.journalNames().includes("stop"));
|
|
173
270
|
});
|
|
174
271
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
staleMsg: string | null;
|
|
179
|
-
gateResult: Awaited<ReturnType<AutoOrchestratorDeps["health"]["preAdvanceGate"]>>;
|
|
180
|
-
expectedKind: "advanced" | "blocked";
|
|
181
|
-
expectedAction?: "pause" | "stop";
|
|
182
|
-
expectedReason?: string;
|
|
183
|
-
expectedGates: string[];
|
|
184
|
-
};
|
|
185
|
-
const scenarios: Scenario[] = [
|
|
186
|
-
{
|
|
187
|
-
name: "pass",
|
|
188
|
-
staleMsg: null,
|
|
189
|
-
gateResult: { kind: "pass" },
|
|
190
|
-
expectedKind: "advanced",
|
|
191
|
-
expectedGates: [
|
|
192
|
-
"resource-version-guard:policy:pass:none:resource version guard passed:",
|
|
193
|
-
"pre-dispatch-health-gate:execution:pass:none:pre-dispatch health gate passed:",
|
|
194
|
-
],
|
|
195
|
-
},
|
|
196
|
-
{
|
|
197
|
-
name: "resource-stale",
|
|
198
|
-
staleMsg: "resources changed since session start",
|
|
199
|
-
gateResult: { kind: "pass" },
|
|
200
|
-
expectedKind: "blocked",
|
|
201
|
-
expectedAction: "pause",
|
|
202
|
-
expectedReason: "resources changed since session start",
|
|
203
|
-
expectedGates: [
|
|
204
|
-
"resource-version-guard:policy:fail:policy:resource version guard blocked dispatch:resources changed since session start",
|
|
205
|
-
],
|
|
206
|
-
},
|
|
207
|
-
{
|
|
208
|
-
name: "health-gate-fail",
|
|
209
|
-
staleMsg: null,
|
|
210
|
-
gateResult: { kind: "fail", reason: "doctor-block" },
|
|
211
|
-
expectedKind: "blocked",
|
|
212
|
-
expectedAction: "pause",
|
|
213
|
-
expectedReason: "doctor-block",
|
|
214
|
-
expectedGates: [
|
|
215
|
-
"resource-version-guard:policy:pass:none:resource version guard passed:",
|
|
216
|
-
"pre-dispatch-health-gate:execution:manual-attention:manual-attention:pre-dispatch health gate blocked dispatch:doctor-block",
|
|
217
|
-
],
|
|
218
|
-
},
|
|
219
|
-
];
|
|
220
|
-
|
|
221
|
-
for (const scenario of scenarios) {
|
|
222
|
-
const gateEvents: string[] = [];
|
|
223
|
-
const { deps } = makeDeps({
|
|
224
|
-
health: {
|
|
225
|
-
checkResourcesStale: () => scenario.staleMsg,
|
|
226
|
-
async preAdvanceGate() { return scenario.gateResult; },
|
|
227
|
-
async postAdvanceRecord() {},
|
|
228
|
-
},
|
|
229
|
-
uokGate: {
|
|
230
|
-
async emit(input) {
|
|
231
|
-
gateEvents.push(
|
|
232
|
-
`${input.gateId}:${input.gateType}:${input.outcome}:${input.failureClass}:${input.rationale}:${input.findings ?? ""}`,
|
|
233
|
-
);
|
|
234
|
-
},
|
|
235
|
-
},
|
|
236
|
-
});
|
|
237
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
238
|
-
const result = await orchestrator.advance();
|
|
239
|
-
|
|
240
|
-
assert.equal(result.kind, scenario.expectedKind, `${scenario.name} result kind`);
|
|
241
|
-
if (scenario.expectedKind === "blocked") {
|
|
242
|
-
assertBlockedResult(result);
|
|
243
|
-
assert.equal(result.action, scenario.expectedAction, `${scenario.name} blocked action`);
|
|
244
|
-
assert.equal(result.reason, scenario.expectedReason, `${scenario.name} blocked reason`);
|
|
245
|
-
}
|
|
246
|
-
assert.deepEqual(gateEvents, scenario.expectedGates, `${scenario.name} gate parity`);
|
|
247
|
-
}
|
|
248
|
-
});
|
|
272
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
273
|
+
// advance(): happy path + ADR-015 invariant sequence
|
|
274
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
249
275
|
|
|
250
|
-
test("advance()
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
checkResourcesStale: () => null,
|
|
254
|
-
async preAdvanceGate() { return { kind: "threw", error: new Error("boom") }; },
|
|
255
|
-
async postAdvanceRecord() {},
|
|
256
|
-
},
|
|
257
|
-
});
|
|
258
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
276
|
+
test("advance() dispatches the resolved unit and journals advance", async (t) => {
|
|
277
|
+
const f = makeFixture();
|
|
278
|
+
t.after(() => f.cleanup());
|
|
259
279
|
|
|
260
|
-
const result = await orchestrator.advance();
|
|
280
|
+
const result = await f.orchestrator.advance();
|
|
261
281
|
|
|
262
282
|
assert.equal(result.kind, "advanced");
|
|
263
|
-
|
|
264
|
-
assert.
|
|
265
|
-
assert.
|
|
283
|
+
if (result.kind !== "advanced") return;
|
|
284
|
+
assert.deepEqual(result.unit, { unitType: "execute-task", unitId: "M001/S01/T01" });
|
|
285
|
+
assert.equal(f.orchestrator.getStatus().phase, "running");
|
|
286
|
+
// Journal records the advance AFTER the invariant gates (lock, health,
|
|
287
|
+
// reconcile, dispatch, tool-contract, worktree) — i.e. no advance-blocked.
|
|
288
|
+
const names = f.journalNames();
|
|
289
|
+
assert.ok(names.includes("advance"));
|
|
290
|
+
assert.ok(!names.includes("advance-blocked"));
|
|
266
291
|
});
|
|
267
292
|
|
|
268
|
-
test("advance()
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
health: {
|
|
272
|
-
checkResourcesStale: () => null,
|
|
273
|
-
async preAdvanceGate() { return { kind: "pass", fixesApplied: ["fix-a", "fix-b"] }; },
|
|
274
|
-
async postAdvanceRecord() {},
|
|
275
|
-
},
|
|
276
|
-
uokGate: {
|
|
277
|
-
async emit(input) {
|
|
278
|
-
if (input.gateId === "pre-dispatch-health-gate" && input.outcome === "pass") {
|
|
279
|
-
observed = input.findings ?? "";
|
|
280
|
-
}
|
|
281
|
-
},
|
|
282
|
-
},
|
|
283
|
-
});
|
|
284
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
293
|
+
test("advance() sets active unit and is reflected in status", async (t) => {
|
|
294
|
+
const f = makeFixture();
|
|
295
|
+
t.after(() => f.cleanup());
|
|
285
296
|
|
|
286
|
-
await orchestrator.advance();
|
|
297
|
+
await f.orchestrator.advance();
|
|
287
298
|
|
|
288
|
-
assert.
|
|
299
|
+
assert.deepEqual(f.orchestrator.getStatus().activeUnit, {
|
|
300
|
+
unitType: "execute-task",
|
|
301
|
+
unitId: "M001/S01/T01",
|
|
302
|
+
});
|
|
289
303
|
});
|
|
290
304
|
|
|
291
|
-
test("
|
|
292
|
-
const
|
|
293
|
-
|
|
305
|
+
test("getStatus() returns defensive copy of activeUnit", async (t) => {
|
|
306
|
+
const f = makeFixture();
|
|
307
|
+
t.after(() => f.cleanup());
|
|
294
308
|
|
|
295
|
-
|
|
309
|
+
await f.orchestrator.advance();
|
|
310
|
+
const snap1 = f.orchestrator.getStatus();
|
|
311
|
+
if (snap1.activeUnit) snap1.activeUnit.unitId = "MUTATED";
|
|
312
|
+
const snap2 = f.orchestrator.getStatus();
|
|
296
313
|
|
|
297
|
-
assert.equal(
|
|
298
|
-
assert.deepEqual(result.unit, { unitType: "execute-task", unitId: "T01" });
|
|
299
|
-
assert.deepEqual(calls, [
|
|
300
|
-
"runtime.lock",
|
|
301
|
-
"health.stale",
|
|
302
|
-
"gate:resource-version-guard:pass",
|
|
303
|
-
"health.pre",
|
|
304
|
-
"gate:pre-dispatch-health-gate:pass",
|
|
305
|
-
"state.reconcile",
|
|
306
|
-
"dispatch.decide",
|
|
307
|
-
"tool.compile",
|
|
308
|
-
"worktree.prepare",
|
|
309
|
-
"journal:advance",
|
|
310
|
-
"worktree.sync",
|
|
311
|
-
"health.post",
|
|
312
|
-
]);
|
|
314
|
+
assert.equal(snap2.activeUnit?.unitId, "M001/S01/T01");
|
|
313
315
|
});
|
|
314
316
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
async reconcileBeforeDispatch() {
|
|
319
|
-
calls.push("state.reconcile");
|
|
320
|
-
return { ok: false, reason: "state drift blocked", stateSnapshot: makeState() };
|
|
321
|
-
},
|
|
322
|
-
},
|
|
323
|
-
});
|
|
324
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
325
|
-
|
|
326
|
-
const result = await orchestrator.advance();
|
|
317
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
318
|
+
// Dispatch passthrough decisions (skip / blocked / no-remaining-units)
|
|
319
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
327
320
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
assert.ok(!calls.includes("dispatch.decide"));
|
|
332
|
-
assert.ok(calls.includes("journal:advance-blocked"));
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
test("advance() blocks before Runtime persistence when Tool Contract fails", async () => {
|
|
336
|
-
const { deps, calls } = makeDeps({
|
|
337
|
-
toolContract: {
|
|
338
|
-
async compileUnitToolContract() {
|
|
339
|
-
calls.push("tool.compile");
|
|
340
|
-
return { ok: false, reason: "unknown Unit" };
|
|
341
|
-
},
|
|
342
|
-
},
|
|
321
|
+
test("advance() keeps running when dispatch intentionally skips a phase", async (t) => {
|
|
322
|
+
const f = makeFixture({
|
|
323
|
+
dispatch: () => ({ action: "skip", matchedRule: "evaluating-gates skipped after marking gates omitted" }),
|
|
343
324
|
});
|
|
344
|
-
|
|
325
|
+
t.after(() => f.cleanup());
|
|
345
326
|
|
|
346
|
-
const result = await orchestrator.advance();
|
|
327
|
+
const result = await f.orchestrator.advance();
|
|
347
328
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
assert.equal(result.
|
|
351
|
-
assert.
|
|
352
|
-
|
|
353
|
-
assert.ok(
|
|
329
|
+
assert.equal(result.kind, "skipped");
|
|
330
|
+
if (result.kind !== "skipped") return;
|
|
331
|
+
assert.equal(result.reason, "evaluating-gates skipped after marking gates omitted");
|
|
332
|
+
assert.equal(f.orchestrator.getStatus().phase, "running");
|
|
333
|
+
const names = f.journalNames();
|
|
334
|
+
assert.ok(names.includes("advance-skipped"));
|
|
335
|
+
assert.ok(!names.includes("advance-stopped"));
|
|
354
336
|
});
|
|
355
337
|
|
|
356
|
-
test("advance()
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
calls.push("worktree.prepare");
|
|
361
|
-
return { ok: false, reason: "worktree invalid" };
|
|
362
|
-
},
|
|
363
|
-
async syncAfterUnit() { calls.push("worktree.sync"); },
|
|
364
|
-
async cleanupOnStop() { calls.push("worktree.cleanup"); },
|
|
365
|
-
},
|
|
338
|
+
test("advance() surfaces dispatch blocker reason instead of generic no remaining units", async (t) => {
|
|
339
|
+
const reason = "Milestone M001 validation verdict is needs-remediation but all slices are complete.";
|
|
340
|
+
const f = makeFixture({
|
|
341
|
+
dispatch: () => ({ action: "stop", reason, level: "warning" }),
|
|
366
342
|
});
|
|
367
|
-
|
|
343
|
+
t.after(() => f.cleanup());
|
|
368
344
|
|
|
369
|
-
const result = await orchestrator.advance();
|
|
345
|
+
const result = await f.orchestrator.advance();
|
|
370
346
|
|
|
371
|
-
|
|
372
|
-
|
|
347
|
+
assert.equal(result.kind, "blocked");
|
|
348
|
+
if (result.kind !== "blocked") return;
|
|
349
|
+
assert.equal(result.reason, reason);
|
|
373
350
|
assert.equal(result.action, "pause");
|
|
374
|
-
|
|
375
|
-
assert.ok(
|
|
376
|
-
assert.ok(
|
|
351
|
+
const names = f.journalNames();
|
|
352
|
+
assert.ok(names.includes("advance-blocked"));
|
|
353
|
+
assert.ok(!names.includes("advance-stopped"));
|
|
377
354
|
});
|
|
378
355
|
|
|
379
|
-
test("advance()
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
async prepareForUnit() {
|
|
383
|
-
calls.push("worktree.prepare");
|
|
384
|
-
return { ok: true, reason: "isolation-not-worktree" };
|
|
385
|
-
},
|
|
386
|
-
async syncAfterUnit() { calls.push("worktree.sync"); },
|
|
387
|
-
async cleanupOnStop() { calls.push("worktree.cleanup"); },
|
|
388
|
-
},
|
|
356
|
+
test("advance() stop level=error blocks with action stop", async (t) => {
|
|
357
|
+
const f = makeFixture({
|
|
358
|
+
dispatch: () => ({ action: "stop", reason: "hard blocker", level: "error" }),
|
|
389
359
|
});
|
|
390
|
-
|
|
360
|
+
t.after(() => f.cleanup());
|
|
391
361
|
|
|
392
|
-
const result = await orchestrator.advance();
|
|
362
|
+
const result = await f.orchestrator.advance();
|
|
393
363
|
|
|
394
|
-
assert.equal(result.kind, "
|
|
395
|
-
|
|
396
|
-
assert.
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
test("advance() stops when dispatch has no next unit", async () => {
|
|
400
|
-
const { deps } = makeDeps({
|
|
401
|
-
dispatch: {
|
|
402
|
-
async decideNextUnit() { return null; },
|
|
403
|
-
},
|
|
404
|
-
});
|
|
405
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
406
|
-
|
|
407
|
-
const result = await orchestrator.advance();
|
|
408
|
-
|
|
409
|
-
assert.equal(result.kind, "stopped");
|
|
410
|
-
assert.equal(orchestrator.getStatus().phase, "stopped");
|
|
364
|
+
assert.equal(result.kind, "blocked");
|
|
365
|
+
if (result.kind !== "blocked") return;
|
|
366
|
+
assert.equal(result.action, "stop");
|
|
411
367
|
});
|
|
412
368
|
|
|
413
|
-
test("advance() reports completion when complete state has no next unit", async () => {
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
activeMilestone: null,
|
|
417
|
-
phase: "complete",
|
|
418
|
-
lastCompletedMilestone: { id: "M001", title: "Milestone" },
|
|
419
|
-
nextAction: "All milestones complete.",
|
|
420
|
-
};
|
|
421
|
-
const { deps } = makeDeps({
|
|
422
|
-
stateReconciliation: {
|
|
423
|
-
async reconcileBeforeDispatch() {
|
|
424
|
-
return { ok: true, stateSnapshot: completeState };
|
|
425
|
-
},
|
|
426
|
-
},
|
|
427
|
-
dispatch: {
|
|
428
|
-
async decideNextUnit() { return null; },
|
|
429
|
-
},
|
|
430
|
-
});
|
|
431
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
369
|
+
test("advance() reports completion when complete state has no next unit", async (t) => {
|
|
370
|
+
const f = makeFixture({ complete: true, noTask: true });
|
|
371
|
+
t.after(() => f.cleanup());
|
|
432
372
|
|
|
433
|
-
const result = await orchestrator.advance();
|
|
373
|
+
const result = await f.orchestrator.advance();
|
|
434
374
|
|
|
435
375
|
assert.equal(result.kind, "stopped");
|
|
376
|
+
if (result.kind !== "stopped") return;
|
|
436
377
|
assert.equal(result.reason, "all milestones complete");
|
|
378
|
+
assert.equal(f.orchestrator.getStatus().phase, "stopped");
|
|
437
379
|
});
|
|
438
380
|
|
|
439
|
-
test("advance()
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
assert.equal(orchestrator.getStatus().phase, "running");
|
|
455
|
-
assert.ok(calls.includes("journal:advance-skipped"));
|
|
456
|
-
assert.ok(!calls.includes("journal:advance-stopped"));
|
|
457
|
-
});
|
|
458
|
-
|
|
459
|
-
test("advance() surfaces dispatch blocker reason instead of generic no remaining units", async () => {
|
|
460
|
-
const { deps, calls } = makeDeps({
|
|
461
|
-
dispatch: {
|
|
462
|
-
async decideNextUnit() {
|
|
463
|
-
return {
|
|
464
|
-
kind: "blocked",
|
|
465
|
-
reason: "Milestone M001 validation verdict is needs-remediation but all slices are complete.",
|
|
466
|
-
action: "pause",
|
|
467
|
-
};
|
|
468
|
-
},
|
|
381
|
+
test("advance() stopped clears previous activeUnit and resets idempotent lock", async (t) => {
|
|
382
|
+
// First advance dispatches; then we make the milestone resolve to no unit by
|
|
383
|
+
// closing it on disk + DB and re-deriving. Simpler: drive a fixture that
|
|
384
|
+
// dispatches once, finalize externally, then the next decision is complete.
|
|
385
|
+
let dispatchOnce = true;
|
|
386
|
+
const f = makeFixture({
|
|
387
|
+
dispatch: () => {
|
|
388
|
+
if (dispatchOnce) {
|
|
389
|
+
dispatchOnce = false;
|
|
390
|
+
return DEFAULT_DISPATCH;
|
|
391
|
+
}
|
|
392
|
+
// After the first advance, signal completion via a benign skip → still
|
|
393
|
+
// exercises the running/active-unit transition. For the stopped path we
|
|
394
|
+
// rely on the complete-state test above.
|
|
395
|
+
return { action: "skip", matchedRule: "done" };
|
|
469
396
|
},
|
|
470
397
|
});
|
|
471
|
-
|
|
398
|
+
t.after(() => f.cleanup());
|
|
472
399
|
|
|
473
|
-
const
|
|
400
|
+
const first = await f.orchestrator.advance();
|
|
401
|
+
assert.equal(first.kind, "advanced");
|
|
474
402
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
assert.equal(
|
|
479
|
-
assert.ok(calls.includes("journal:advance-blocked"));
|
|
480
|
-
assert.ok(!calls.includes("journal:advance-stopped"));
|
|
403
|
+
const second = await f.orchestrator.advance();
|
|
404
|
+
assert.equal(second.kind, "skipped");
|
|
405
|
+
// skip clears activeUnit
|
|
406
|
+
assert.equal(f.orchestrator.getStatus().activeUnit, undefined);
|
|
481
407
|
});
|
|
482
408
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
const result = await orchestrator.resume();
|
|
488
|
-
|
|
489
|
-
assert.equal(result.kind, "resumed");
|
|
490
|
-
assert.equal(orchestrator.getStatus().phase, "running");
|
|
491
|
-
assert.ok(!calls.includes("journal:advance"));
|
|
492
|
-
assert.ok(!calls.includes("dispatch.decide"));
|
|
493
|
-
});
|
|
409
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
410
|
+
// Idempotency + finalized guard + stuck-loop ring (issues #5786 / #5787 / #415)
|
|
411
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
494
412
|
|
|
495
|
-
test("advance()
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
async ensureLockOwnership() { throw new Error("lock lost"); },
|
|
499
|
-
async journalTransition(event) { calls.push(`journal:${event.name}`); },
|
|
500
|
-
},
|
|
501
|
-
recovery: {
|
|
502
|
-
async classifyAndRecover() { return { action: "escalate", reason: "needs manual" }; },
|
|
503
|
-
},
|
|
504
|
-
});
|
|
505
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
413
|
+
test("advance() is idempotent for the same active unit", async (t) => {
|
|
414
|
+
const f = makeFixture();
|
|
415
|
+
t.after(() => f.cleanup());
|
|
506
416
|
|
|
507
|
-
const
|
|
417
|
+
const first = await f.orchestrator.advance();
|
|
418
|
+
const second = await f.orchestrator.advance();
|
|
508
419
|
|
|
509
|
-
assert.equal(
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
420
|
+
assert.equal(first.kind, "advanced");
|
|
421
|
+
if (first.kind === "advanced") {
|
|
422
|
+
assert.deepEqual(first.unit, { unitType: "execute-task", unitId: "M001/S01/T01" });
|
|
423
|
+
}
|
|
424
|
+
assert.equal(second.kind, "blocked");
|
|
425
|
+
if (second.kind !== "blocked") return;
|
|
426
|
+
assert.equal(second.reason, "idempotent advance: unit already active");
|
|
427
|
+
assert.equal(second.action, "pause");
|
|
513
428
|
});
|
|
514
429
|
|
|
515
|
-
test("
|
|
516
|
-
const
|
|
517
|
-
|
|
430
|
+
test("idempotency block fires with its own reason before saturation", async (t) => {
|
|
431
|
+
const f = makeFixture();
|
|
432
|
+
t.after(() => f.cleanup());
|
|
518
433
|
|
|
519
|
-
const first = await orchestrator.advance();
|
|
520
|
-
const second = await orchestrator.advance();
|
|
434
|
+
const first = await f.orchestrator.advance();
|
|
435
|
+
const second = await f.orchestrator.advance();
|
|
521
436
|
|
|
522
437
|
assert.equal(first.kind, "advanced");
|
|
523
|
-
assert.deepEqual(first.unit, { unitType: "execute-task", unitId: "T01" });
|
|
524
438
|
assert.equal(second.kind, "blocked");
|
|
439
|
+
if (second.kind !== "blocked") return;
|
|
525
440
|
assert.equal(second.reason, "idempotent advance: unit already active");
|
|
526
441
|
assert.equal(second.action, "pause");
|
|
527
|
-
|
|
528
|
-
const prepareCalls = calls.filter((c) => c === "worktree.prepare").length;
|
|
529
|
-
assert.equal(prepareCalls, 1);
|
|
530
442
|
});
|
|
531
443
|
|
|
532
|
-
test("completeActiveUnit clears in-flight idempotency and stops stale same-unit advance", async () => {
|
|
533
|
-
const
|
|
534
|
-
|
|
444
|
+
test("completeActiveUnit clears in-flight idempotency and stops stale same-unit advance", async (t) => {
|
|
445
|
+
const f = makeFixture();
|
|
446
|
+
t.after(() => f.cleanup());
|
|
535
447
|
|
|
536
|
-
const first = await orchestrator.advance();
|
|
448
|
+
const first = await f.orchestrator.advance();
|
|
537
449
|
assert.equal(first.kind, "advanced");
|
|
538
450
|
if (first.kind !== "advanced") throw new Error("expected first advance");
|
|
539
451
|
|
|
540
|
-
await orchestrator.completeActiveUnit(first.unit);
|
|
541
|
-
const second = await orchestrator.advance();
|
|
452
|
+
await f.orchestrator.completeActiveUnit(first.unit);
|
|
453
|
+
const second = await f.orchestrator.advance();
|
|
542
454
|
|
|
543
|
-
assert.equal(orchestrator.getStatus().activeUnit, undefined);
|
|
455
|
+
assert.equal(f.orchestrator.getStatus().activeUnit, undefined);
|
|
544
456
|
assert.equal(second.kind, "blocked");
|
|
545
457
|
if (second.kind !== "blocked") throw new Error("expected stale same-unit block");
|
|
546
458
|
assert.equal(second.action, "stop");
|
|
547
|
-
assert.equal(second.reason, "state did not advance after finalized execute-task T01");
|
|
548
|
-
assert.ok(
|
|
549
|
-
const prepareCalls = calls.filter((c) => c === "worktree.prepare").length;
|
|
550
|
-
assert.equal(prepareCalls, 1, "stale same-unit advance must not prepare or redispatch");
|
|
459
|
+
assert.equal(second.reason, "state did not advance after finalized execute-task M001/S01/T01");
|
|
460
|
+
assert.ok(f.journalNames().includes("unit-finalized"));
|
|
551
461
|
});
|
|
552
462
|
|
|
553
|
-
test("
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
463
|
+
test("#442: finalized-repeat recovers (skipped) when the unit's artifact already exists on disk", async (t) => {
|
|
464
|
+
// plan-milestone's expected artifact is the ROADMAP, which the fixture
|
|
465
|
+
// already writes — so verifyExpectedArtifact returns true. This is the legacy
|
|
466
|
+
// stuck-recovery scenario (unit completed on disk, DB row stale): instead of
|
|
467
|
+
// the finalized-repeat HARD-STOP, #442 verify-and-recover should refresh +
|
|
468
|
+
// skip so the loop can progress. plan-milestone is deliberately NOT one of
|
|
469
|
+
// the DB-refreshing unit types, so the recovery stays side-effect-light.
|
|
470
|
+
const f = makeFixture({
|
|
471
|
+
dispatch: () => ({ action: "dispatch", unitType: "plan-milestone", unitId: "M001", prompt: "p" }),
|
|
561
472
|
});
|
|
562
|
-
|
|
473
|
+
t.after(() => f.cleanup());
|
|
474
|
+
|
|
475
|
+
const first = await f.orchestrator.advance();
|
|
476
|
+
if (first.kind !== "advanced") {
|
|
477
|
+
throw new Error(`expected advanced, got ${first.kind}: ${(first as { reason?: string }).reason ?? ""}`);
|
|
478
|
+
}
|
|
479
|
+
await f.orchestrator.completeActiveUnit(first.unit);
|
|
563
480
|
|
|
564
|
-
const
|
|
481
|
+
const second = await f.orchestrator.advance();
|
|
482
|
+
assert.equal(second.kind, "skipped", "should recover via artifact verification, not hard-stop");
|
|
483
|
+
if (second.kind !== "skipped") throw new Error("expected skipped recovery");
|
|
484
|
+
assert.match(second.reason, /stuck-recovery/);
|
|
485
|
+
assert.ok(f.journalNames().includes("advance-skipped"));
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("completeActiveUnit allows a different next unit to advance", async (t) => {
|
|
489
|
+
let nextTaskId = "M001/S01/T01";
|
|
490
|
+
const f = makeFixture({
|
|
491
|
+
dispatch: () => ({ action: "dispatch", unitType: "execute-task", unitId: nextTaskId, prompt: "p" }),
|
|
492
|
+
});
|
|
493
|
+
t.after(() => f.cleanup());
|
|
494
|
+
|
|
495
|
+
const first = await f.orchestrator.advance();
|
|
565
496
|
assert.equal(first.kind, "advanced");
|
|
566
497
|
if (first.kind !== "advanced") throw new Error("expected first advance");
|
|
567
498
|
|
|
568
|
-
await orchestrator.completeActiveUnit(first.unit);
|
|
569
|
-
nextTaskId = "T02";
|
|
570
|
-
const second = await orchestrator.advance();
|
|
499
|
+
await f.orchestrator.completeActiveUnit(first.unit);
|
|
500
|
+
nextTaskId = "M001/S01/T02";
|
|
501
|
+
const second = await f.orchestrator.advance();
|
|
571
502
|
|
|
572
503
|
assert.equal(second.kind, "advanced");
|
|
573
504
|
if (second.kind !== "advanced") throw new Error("expected second advance");
|
|
574
|
-
assert.deepEqual(second.unit, { unitType: "execute-task", unitId: "T02" });
|
|
505
|
+
assert.deepEqual(second.unit, { unitType: "execute-task", unitId: "M001/S01/T02" });
|
|
575
506
|
});
|
|
576
507
|
|
|
577
|
-
test("completeActiveUnit guard survives an intervening advance and blocks X→Y→X re-dispatch", async () => {
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
const { deps } = makeDeps({
|
|
582
|
-
dispatch: {
|
|
583
|
-
async decideNextUnit() {
|
|
584
|
-
return { unitType: "execute-task", unitId: nextTaskId, reason: "ready", preconditions: [] };
|
|
585
|
-
},
|
|
586
|
-
},
|
|
508
|
+
test("completeActiveUnit guard survives an intervening advance and blocks X→Y→X re-dispatch (#415)", async (t) => {
|
|
509
|
+
let nextTaskId = "M001/S01/T01";
|
|
510
|
+
const f = makeFixture({
|
|
511
|
+
dispatch: () => ({ action: "dispatch", unitType: "execute-task", unitId: nextTaskId, prompt: "p" }),
|
|
587
512
|
});
|
|
588
|
-
|
|
513
|
+
t.after(() => f.cleanup());
|
|
589
514
|
|
|
590
|
-
|
|
591
|
-
const first = await orchestrator.advance();
|
|
515
|
+
const first = await f.orchestrator.advance();
|
|
592
516
|
assert.equal(first.kind, "advanced");
|
|
593
517
|
if (first.kind !== "advanced") throw new Error("expected first advance");
|
|
594
518
|
|
|
595
|
-
|
|
596
|
-
await orchestrator.completeActiveUnit(first.unit);
|
|
519
|
+
await f.orchestrator.completeActiveUnit(first.unit);
|
|
597
520
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
const second = await orchestrator.advance();
|
|
521
|
+
nextTaskId = "M001/S01/T02";
|
|
522
|
+
const second = await f.orchestrator.advance();
|
|
601
523
|
assert.equal(second.kind, "advanced");
|
|
602
524
|
if (second.kind !== "advanced") throw new Error("expected second advance (T02)");
|
|
603
|
-
assert.deepEqual(second.unit, { unitType: "execute-task", unitId: "T02" });
|
|
525
|
+
assert.deepEqual(second.unit, { unitType: "execute-task", unitId: "M001/S01/T02" });
|
|
604
526
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
const third = await orchestrator.advance();
|
|
527
|
+
nextTaskId = "M001/S01/T01";
|
|
528
|
+
const third = await f.orchestrator.advance();
|
|
608
529
|
assert.equal(third.kind, "blocked");
|
|
609
530
|
if (third.kind !== "blocked") throw new Error("expected X→Y→X re-dispatch to be blocked");
|
|
610
531
|
assert.equal(third.action, "stop");
|
|
611
|
-
assert.equal(third.reason, "state did not advance after finalized execute-task T01");
|
|
532
|
+
assert.equal(third.reason, "state did not advance after finalized execute-task M001/S01/T01");
|
|
612
533
|
});
|
|
613
534
|
|
|
614
|
-
test("retryActiveUnit clears in-flight idempotency without marking the unit finalized", async () => {
|
|
615
|
-
const
|
|
616
|
-
|
|
535
|
+
test("retryActiveUnit clears in-flight idempotency without marking the unit finalized", async (t) => {
|
|
536
|
+
const f = makeFixture();
|
|
537
|
+
t.after(() => f.cleanup());
|
|
617
538
|
|
|
618
|
-
const first = await orchestrator.advance();
|
|
539
|
+
const first = await f.orchestrator.advance();
|
|
619
540
|
assert.equal(first.kind, "advanced");
|
|
620
541
|
if (first.kind !== "advanced") throw new Error("expected first advance");
|
|
621
542
|
|
|
622
|
-
await orchestrator.retryActiveUnit(first.unit);
|
|
623
|
-
const second = await orchestrator.advance();
|
|
543
|
+
await f.orchestrator.retryActiveUnit(first.unit);
|
|
544
|
+
const second = await f.orchestrator.advance();
|
|
624
545
|
|
|
625
546
|
assert.equal(second.kind, "advanced");
|
|
626
547
|
if (second.kind !== "advanced") throw new Error("expected retry advance");
|
|
627
548
|
assert.deepEqual(second.unit, first.unit);
|
|
628
|
-
assert.ok(
|
|
629
|
-
const prepareCalls = calls.filter((c) => c === "worktree.prepare").length;
|
|
630
|
-
assert.equal(prepareCalls, 2, "retry should intentionally redispatch the same unit");
|
|
549
|
+
assert.ok(f.journalNames().includes("unit-retry"));
|
|
631
550
|
});
|
|
632
551
|
|
|
633
|
-
test("retryActiveUnit clears finalized same-unit guard for post-hook retries", async () => {
|
|
634
|
-
const
|
|
635
|
-
|
|
552
|
+
test("retryActiveUnit clears finalized same-unit guard for post-hook retries", async (t) => {
|
|
553
|
+
const f = makeFixture();
|
|
554
|
+
t.after(() => f.cleanup());
|
|
636
555
|
|
|
637
|
-
const first = await orchestrator.advance();
|
|
556
|
+
const first = await f.orchestrator.advance();
|
|
638
557
|
assert.equal(first.kind, "advanced");
|
|
639
558
|
if (first.kind !== "advanced") throw new Error("expected first advance");
|
|
640
559
|
|
|
641
|
-
await orchestrator.completeActiveUnit(first.unit);
|
|
642
|
-
await orchestrator.retryActiveUnit(first.unit);
|
|
643
|
-
const second = await orchestrator.advance();
|
|
560
|
+
await f.orchestrator.completeActiveUnit(first.unit);
|
|
561
|
+
await f.orchestrator.retryActiveUnit(first.unit);
|
|
562
|
+
const second = await f.orchestrator.advance();
|
|
644
563
|
|
|
645
564
|
assert.equal(second.kind, "advanced");
|
|
646
565
|
if (second.kind !== "advanced") throw new Error("expected retry advance");
|
|
647
566
|
assert.deepEqual(second.unit, first.unit);
|
|
648
|
-
|
|
649
|
-
assert.ok(
|
|
650
|
-
|
|
651
|
-
assert.equal(prepareCalls, 2, "post-hook retry should redispatch the finalized unit");
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
test("resume() re-enters running phase", async () => {
|
|
655
|
-
const { deps } = makeDeps();
|
|
656
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
657
|
-
|
|
658
|
-
const result = await orchestrator.resume();
|
|
659
|
-
|
|
660
|
-
assert.equal(result.kind, "resumed");
|
|
661
|
-
assert.equal(orchestrator.getStatus().phase, "running");
|
|
567
|
+
const names = f.journalNames();
|
|
568
|
+
assert.ok(names.includes("unit-finalized"));
|
|
569
|
+
assert.ok(names.includes("unit-retry"));
|
|
662
570
|
});
|
|
663
571
|
|
|
664
|
-
test("resume() clears idempotent lock and allows re-advance", async () => {
|
|
665
|
-
const
|
|
666
|
-
|
|
572
|
+
test("resume() clears idempotent lock and allows re-advance", async (t) => {
|
|
573
|
+
const f = makeFixture();
|
|
574
|
+
t.after(() => f.cleanup());
|
|
667
575
|
|
|
668
|
-
const first = await orchestrator.advance();
|
|
669
|
-
const blocked = await orchestrator.advance();
|
|
670
|
-
const resumed = await orchestrator.resume();
|
|
671
|
-
const next = await orchestrator.advance();
|
|
576
|
+
const first = await f.orchestrator.advance();
|
|
577
|
+
const blocked = await f.orchestrator.advance();
|
|
578
|
+
const resumed = await f.orchestrator.resume();
|
|
579
|
+
const next = await f.orchestrator.advance();
|
|
672
580
|
|
|
673
581
|
assert.equal(first.kind, "advanced");
|
|
674
582
|
assert.equal(blocked.kind, "blocked");
|
|
@@ -676,263 +584,81 @@ test("resume() clears idempotent lock and allows re-advance", async () => {
|
|
|
676
584
|
assert.equal(next.kind, "advanced");
|
|
677
585
|
});
|
|
678
586
|
|
|
679
|
-
test("
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
const before = orchestrator.getStatus().transitionCount;
|
|
684
|
-
await orchestrator.start({ basePath: "/tmp/project", trigger: "manual" });
|
|
685
|
-
const afterStart = orchestrator.getStatus().transitionCount;
|
|
686
|
-
await orchestrator.stop("done");
|
|
687
|
-
const afterStop = orchestrator.getStatus().transitionCount;
|
|
688
|
-
|
|
689
|
-
assert.ok(afterStart > before);
|
|
690
|
-
assert.ok(afterStop > afterStart);
|
|
691
|
-
});
|
|
692
|
-
|
|
693
|
-
test("stop() clears idempotent unit lock so advance can run again", async () => {
|
|
694
|
-
const { deps } = makeDeps();
|
|
695
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
587
|
+
test("start() clears prior idempotent lock", async (t) => {
|
|
588
|
+
const f = makeFixture();
|
|
589
|
+
t.after(() => f.cleanup());
|
|
696
590
|
|
|
697
|
-
|
|
698
|
-
const blocked = await orchestrator.advance();
|
|
699
|
-
const
|
|
700
|
-
const
|
|
701
|
-
|
|
702
|
-
assert.equal(first.kind, "advanced");
|
|
703
|
-
assert.equal(blocked.kind, "blocked");
|
|
704
|
-
assert.equal(stopped.kind, "stopped");
|
|
705
|
-
assert.equal(second.kind, "advanced");
|
|
706
|
-
});
|
|
707
|
-
|
|
708
|
-
test("advance() stopped clears previous activeUnit", async () => {
|
|
709
|
-
let first = true;
|
|
710
|
-
const { deps } = makeDeps({
|
|
711
|
-
dispatch: {
|
|
712
|
-
async decideNextUnit() {
|
|
713
|
-
if (first) {
|
|
714
|
-
first = false;
|
|
715
|
-
return { unitType: "execute-task", unitId: "T01", reason: "ready", preconditions: [] };
|
|
716
|
-
}
|
|
717
|
-
return null;
|
|
718
|
-
},
|
|
719
|
-
},
|
|
720
|
-
});
|
|
721
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
722
|
-
|
|
723
|
-
await orchestrator.advance();
|
|
724
|
-
const stopped = await orchestrator.advance();
|
|
725
|
-
|
|
726
|
-
assert.equal(stopped.kind, "stopped");
|
|
727
|
-
assert.equal(orchestrator.getStatus().activeUnit, undefined);
|
|
728
|
-
});
|
|
729
|
-
|
|
730
|
-
test("recovery stop clears activeUnit", async () => {
|
|
731
|
-
const { deps, calls } = makeDeps({
|
|
732
|
-
runtime: {
|
|
733
|
-
async ensureLockOwnership() { throw new Error("boom"); },
|
|
734
|
-
async journalTransition(event) { calls.push(`journal:${event.name}`); },
|
|
735
|
-
},
|
|
736
|
-
recovery: {
|
|
737
|
-
async classifyAndRecover() { return { action: "stop", reason: "fatal" }; },
|
|
738
|
-
},
|
|
739
|
-
});
|
|
740
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
741
|
-
|
|
742
|
-
const result = await orchestrator.advance();
|
|
743
|
-
|
|
744
|
-
assert.equal(result.kind, "stopped");
|
|
745
|
-
assert.equal(orchestrator.getStatus().activeUnit, undefined);
|
|
746
|
-
assert.ok(calls.includes("journal:advance-stopped"));
|
|
747
|
-
assert.ok(calls.includes("notify:stopped"));
|
|
748
|
-
assert.ok(!calls.includes("notify:error"));
|
|
749
|
-
});
|
|
750
|
-
|
|
751
|
-
test("recovery retry maps to paused result", async () => {
|
|
752
|
-
const { deps, calls } = makeDeps({
|
|
753
|
-
runtime: {
|
|
754
|
-
async ensureLockOwnership() { throw new Error("boom"); },
|
|
755
|
-
async journalTransition(event) { calls.push(`journal:${event.name}`); },
|
|
756
|
-
},
|
|
757
|
-
recovery: {
|
|
758
|
-
async classifyAndRecover() { return { action: "retry", reason: "transient" }; },
|
|
759
|
-
},
|
|
760
|
-
});
|
|
761
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
762
|
-
|
|
763
|
-
const result = await orchestrator.advance();
|
|
764
|
-
|
|
765
|
-
assert.equal(result.kind, "paused");
|
|
766
|
-
assert.equal(result.reason, "transient");
|
|
767
|
-
assert.equal(orchestrator.getStatus().phase, "paused");
|
|
768
|
-
assert.ok(calls.includes("journal:advance-paused"));
|
|
769
|
-
assert.ok(calls.includes("notify:pause"));
|
|
770
|
-
});
|
|
771
|
-
|
|
772
|
-
test("getStatus() returns defensive copy of activeUnit", async () => {
|
|
773
|
-
const { deps } = makeDeps();
|
|
774
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
775
|
-
|
|
776
|
-
await orchestrator.advance();
|
|
777
|
-
const snap1 = orchestrator.getStatus();
|
|
778
|
-
if (snap1.activeUnit) snap1.activeUnit.unitId = "MUTATED";
|
|
779
|
-
const snap2 = orchestrator.getStatus();
|
|
780
|
-
|
|
781
|
-
assert.equal(snap2.activeUnit?.unitId, "T01");
|
|
782
|
-
});
|
|
783
|
-
|
|
784
|
-
test("start() clears prior idempotent lock", async () => {
|
|
785
|
-
const { deps } = makeDeps();
|
|
786
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
787
|
-
|
|
788
|
-
await orchestrator.advance();
|
|
789
|
-
const blocked = await orchestrator.advance();
|
|
790
|
-
const restarted = await orchestrator.start({ basePath: "/tmp/project", trigger: "manual" });
|
|
791
|
-
const next = await orchestrator.advance();
|
|
591
|
+
await f.orchestrator.advance();
|
|
592
|
+
const blocked = await f.orchestrator.advance();
|
|
593
|
+
const restarted = await f.orchestrator.start(SESSION_CONTEXT);
|
|
594
|
+
const next = await f.orchestrator.advance();
|
|
792
595
|
|
|
793
596
|
assert.equal(blocked.kind, "blocked");
|
|
794
597
|
assert.equal(restarted.kind, "started");
|
|
795
598
|
assert.equal(next.kind, "advanced");
|
|
796
599
|
});
|
|
797
600
|
|
|
798
|
-
test("
|
|
799
|
-
const
|
|
800
|
-
|
|
801
|
-
async ensureLockOwnership() { throw new Error("boom"); },
|
|
802
|
-
async journalTransition(event) { calls.push(`journal:${event.name}`); },
|
|
803
|
-
},
|
|
804
|
-
recovery: {
|
|
805
|
-
async classifyAndRecover() { return { action: "escalate", reason: "needs manual" }; },
|
|
806
|
-
},
|
|
807
|
-
});
|
|
808
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
809
|
-
|
|
810
|
-
await orchestrator.advance();
|
|
811
|
-
|
|
812
|
-
assert.ok(calls.includes("notify:error"));
|
|
813
|
-
});
|
|
814
|
-
|
|
815
|
-
test("blocked path journals advance-blocked", async () => {
|
|
816
|
-
const { deps, calls } = makeDeps();
|
|
817
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
818
|
-
|
|
819
|
-
await orchestrator.advance();
|
|
820
|
-
await orchestrator.advance();
|
|
821
|
-
|
|
822
|
-
assert.ok(calls.includes("journal:advance-blocked"));
|
|
823
|
-
});
|
|
601
|
+
test("stop() clears idempotent unit lock so advance can run again", async (t) => {
|
|
602
|
+
const f = makeFixture();
|
|
603
|
+
t.after(() => f.cleanup());
|
|
824
604
|
|
|
825
|
-
|
|
826
|
-
const
|
|
827
|
-
const
|
|
828
|
-
|
|
829
|
-
await orchestrator.advance();
|
|
830
|
-
await orchestrator.advance();
|
|
831
|
-
|
|
832
|
-
assert.ok(calls.includes("health.post"));
|
|
833
|
-
});
|
|
834
|
-
|
|
835
|
-
test("start() emits start notification", async () => {
|
|
836
|
-
const { deps, calls } = makeDeps();
|
|
837
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
838
|
-
|
|
839
|
-
await orchestrator.start({ basePath: "/tmp/project", trigger: "manual" });
|
|
840
|
-
|
|
841
|
-
assert.ok(calls.includes("notify:start"));
|
|
842
|
-
});
|
|
843
|
-
|
|
844
|
-
test("resume() emits resume notification", async () => {
|
|
845
|
-
const { deps, calls } = makeDeps();
|
|
846
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
847
|
-
|
|
848
|
-
await orchestrator.resume();
|
|
849
|
-
|
|
850
|
-
assert.ok(calls.includes("notify:resume"));
|
|
851
|
-
});
|
|
852
|
-
|
|
853
|
-
test("stopped with no remaining units clears idempotent lock for next advance", async () => {
|
|
854
|
-
let callCount = 0;
|
|
855
|
-
const { deps } = makeDeps({
|
|
856
|
-
dispatch: {
|
|
857
|
-
async decideNextUnit() {
|
|
858
|
-
callCount += 1;
|
|
859
|
-
if (callCount === 2) return null;
|
|
860
|
-
return { unitType: "execute-task", unitId: "T01", reason: "ready", preconditions: [] };
|
|
861
|
-
},
|
|
862
|
-
},
|
|
863
|
-
});
|
|
864
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
865
|
-
|
|
866
|
-
const first = await orchestrator.advance();
|
|
867
|
-
const stopped = await orchestrator.advance();
|
|
868
|
-
const after = await orchestrator.advance();
|
|
605
|
+
const first = await f.orchestrator.advance();
|
|
606
|
+
const blocked = await f.orchestrator.advance();
|
|
607
|
+
const stopped = await f.orchestrator.stop("reset");
|
|
608
|
+
const second = await f.orchestrator.advance();
|
|
869
609
|
|
|
870
610
|
assert.equal(first.kind, "advanced");
|
|
611
|
+
assert.equal(blocked.kind, "blocked");
|
|
871
612
|
assert.equal(stopped.kind, "stopped");
|
|
872
|
-
assert.equal(
|
|
613
|
+
assert.equal(second.kind, "advanced");
|
|
873
614
|
});
|
|
874
615
|
|
|
875
|
-
test("
|
|
876
|
-
const
|
|
877
|
-
|
|
616
|
+
test("blocked path journals advance-blocked and records a health snapshot", async (t) => {
|
|
617
|
+
const f = makeFixture();
|
|
618
|
+
t.after(() => f.cleanup());
|
|
878
619
|
|
|
879
|
-
|
|
620
|
+
await f.orchestrator.advance();
|
|
621
|
+
await f.orchestrator.advance();
|
|
880
622
|
|
|
881
|
-
assert.
|
|
882
|
-
assert.equal(orchestrator.getStatus().phase, "stopped");
|
|
883
|
-
assert.ok(calls.includes("worktree.cleanup"));
|
|
884
|
-
assert.ok(calls.includes("journal:stop"));
|
|
885
|
-
assert.ok(calls.includes("notify:stop"));
|
|
623
|
+
assert.ok(f.journalNames().includes("advance-blocked"));
|
|
886
624
|
});
|
|
887
625
|
|
|
888
|
-
//
|
|
889
|
-
// Stuck-loop ring buffer (issue #5787)
|
|
890
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
626
|
+
// ─── Stuck-loop ring buffer (issue #5787) ──────────────────────────────────
|
|
891
627
|
|
|
892
628
|
test("STUCK_WINDOW_SIZE matches the legacy auto/phases.ts constant", () => {
|
|
893
629
|
assert.equal(STUCK_WINDOW_SIZE, 6);
|
|
894
630
|
});
|
|
895
631
|
|
|
896
|
-
test("stuck-loop: empty ring on a freshly constructed orchestrator advances normally", async () => {
|
|
897
|
-
const
|
|
898
|
-
|
|
632
|
+
test("stuck-loop: empty ring on a freshly constructed orchestrator advances normally", async (t) => {
|
|
633
|
+
const f = makeFixture();
|
|
634
|
+
t.after(() => f.cleanup());
|
|
899
635
|
|
|
900
|
-
const result = await orchestrator.advance();
|
|
636
|
+
const result = await f.orchestrator.advance();
|
|
901
637
|
|
|
902
638
|
assert.equal(result.kind, "advanced");
|
|
903
639
|
});
|
|
904
640
|
|
|
905
|
-
test("stuck-loop: partial fill of mixed units does not block", async () => {
|
|
906
|
-
// Alternate A/B for STUCK_WINDOW_SIZE rounds. No single key saturates the
|
|
907
|
-
// window, so neither idempotency nor stuck-loop should fire.
|
|
641
|
+
test("stuck-loop: partial fill of mixed units does not block", async (t) => {
|
|
908
642
|
let i = 0;
|
|
909
|
-
const sequence = ["A", "B", "A", "B", "A", "B"];
|
|
910
|
-
const
|
|
911
|
-
dispatch: {
|
|
912
|
-
async decideNextUnit() {
|
|
913
|
-
const id = sequence[i++ % sequence.length];
|
|
914
|
-
return { unitType: "execute-task", unitId: id, reason: "ready", preconditions: [] };
|
|
915
|
-
},
|
|
916
|
-
},
|
|
643
|
+
const sequence = ["M001/S01/A", "M001/S01/B", "M001/S01/A", "M001/S01/B", "M001/S01/A", "M001/S01/B"];
|
|
644
|
+
const f = makeFixture({
|
|
645
|
+
dispatch: () => ({ action: "dispatch", unitType: "execute-task", unitId: sequence[i++ % sequence.length], prompt: "p" }),
|
|
917
646
|
});
|
|
918
|
-
|
|
647
|
+
t.after(() => f.cleanup());
|
|
919
648
|
|
|
920
649
|
for (let round = 0; round < STUCK_WINDOW_SIZE; round++) {
|
|
921
|
-
const result = await orchestrator.advance();
|
|
650
|
+
const result = await f.orchestrator.advance();
|
|
922
651
|
assert.equal(result.kind, "advanced", `round ${round} should advance, got ${result.kind}`);
|
|
923
652
|
}
|
|
924
653
|
});
|
|
925
654
|
|
|
926
|
-
test("stuck-loop: ring saturated with same unit blocks with action 'stop' and stuck-loop reason", async () => {
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
// The STUCK_WINDOW_SIZE'th call sees a saturated ring and returns stuck-loop.
|
|
930
|
-
const { deps } = makeDeps();
|
|
931
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
655
|
+
test("stuck-loop: ring saturated with same unit blocks with action 'stop' and stuck-loop reason", async (t) => {
|
|
656
|
+
const f = makeFixture();
|
|
657
|
+
t.after(() => f.cleanup());
|
|
932
658
|
|
|
933
|
-
const results: Awaited<ReturnType<typeof orchestrator.advance>>[] = [];
|
|
659
|
+
const results: Awaited<ReturnType<typeof f.orchestrator.advance>>[] = [];
|
|
934
660
|
for (let i = 0; i < STUCK_WINDOW_SIZE; i++) {
|
|
935
|
-
results.push(await orchestrator.advance());
|
|
661
|
+
results.push(await f.orchestrator.advance());
|
|
936
662
|
}
|
|
937
663
|
|
|
938
664
|
// First call advances.
|
|
@@ -952,88 +678,108 @@ test("stuck-loop: ring saturated with same unit blocks with action 'stop' and st
|
|
|
952
678
|
assert.equal(last.kind, "blocked");
|
|
953
679
|
if (last.kind !== "blocked") return;
|
|
954
680
|
assert.equal(last.action, "stop");
|
|
955
|
-
assert.equal(last.reason, `stuck-loop: execute-task:T01 picked ${STUCK_WINDOW_SIZE} times`);
|
|
956
|
-
});
|
|
957
|
-
|
|
958
|
-
test("stuck-loop: idempotency block continues to fire with its own reason before saturation", async () => {
|
|
959
|
-
// Two identical calls should produce idempotent (not stuck-loop). Ensures the
|
|
960
|
-
// existing idempotency block is not absorbed by the new check.
|
|
961
|
-
const { deps } = makeDeps();
|
|
962
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
963
|
-
|
|
964
|
-
const first = await orchestrator.advance();
|
|
965
|
-
const second = await orchestrator.advance();
|
|
966
|
-
|
|
967
|
-
assert.equal(first.kind, "advanced");
|
|
968
|
-
assert.equal(second.kind, "blocked");
|
|
969
|
-
assert.equal(second.reason, "idempotent advance: unit already active");
|
|
970
|
-
assert.equal(second.action, "pause");
|
|
681
|
+
assert.equal(last.reason, `stuck-loop: execute-task:M001/S01/T01 picked ${STUCK_WINDOW_SIZE} times`);
|
|
971
682
|
});
|
|
972
683
|
|
|
973
|
-
test("stuck-loop: start() resets the ring so a fresh saturation cycle is required", async () => {
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
const { deps } = makeDeps();
|
|
977
|
-
const orchestrator = createAutoOrchestrator(deps);
|
|
684
|
+
test("stuck-loop: start() resets the ring so a fresh saturation cycle is required", async (t) => {
|
|
685
|
+
const f = makeFixture();
|
|
686
|
+
t.after(() => f.cleanup());
|
|
978
687
|
|
|
979
688
|
for (let i = 0; i < STUCK_WINDOW_SIZE - 1; i++) {
|
|
980
|
-
await orchestrator.advance();
|
|
689
|
+
await f.orchestrator.advance();
|
|
981
690
|
}
|
|
982
691
|
|
|
983
|
-
const restarted = await orchestrator.start(
|
|
692
|
+
const restarted = await f.orchestrator.start(SESSION_CONTEXT);
|
|
984
693
|
assert.equal(restarted.kind, "started");
|
|
985
694
|
|
|
986
|
-
|
|
987
|
-
// no longer pre-dispatches and the ring was reset.
|
|
988
|
-
const next = await orchestrator.advance();
|
|
695
|
+
const next = await f.orchestrator.advance();
|
|
989
696
|
assert.equal(next.kind, "advanced");
|
|
990
697
|
});
|
|
991
698
|
|
|
992
|
-
test("stuck-loop: resume() resets the ring", async () => {
|
|
993
|
-
const
|
|
994
|
-
|
|
699
|
+
test("stuck-loop: resume() resets the ring", async (t) => {
|
|
700
|
+
const f = makeFixture();
|
|
701
|
+
t.after(() => f.cleanup());
|
|
995
702
|
|
|
996
703
|
for (let i = 0; i < STUCK_WINDOW_SIZE - 1; i++) {
|
|
997
|
-
await orchestrator.advance();
|
|
704
|
+
await f.orchestrator.advance();
|
|
998
705
|
}
|
|
999
706
|
|
|
1000
|
-
const resumed = await orchestrator.resume();
|
|
707
|
+
const resumed = await f.orchestrator.resume();
|
|
1001
708
|
assert.equal(resumed.kind, "resumed");
|
|
1002
709
|
|
|
1003
|
-
const next = await orchestrator.advance();
|
|
710
|
+
const next = await f.orchestrator.advance();
|
|
1004
711
|
assert.equal(next.kind, "advanced");
|
|
1005
712
|
});
|
|
1006
713
|
|
|
1007
|
-
test("stuck-loop: stop() resets the ring", async () => {
|
|
1008
|
-
const
|
|
1009
|
-
|
|
714
|
+
test("stuck-loop: stop() resets the ring", async (t) => {
|
|
715
|
+
const f = makeFixture();
|
|
716
|
+
t.after(() => f.cleanup());
|
|
1010
717
|
|
|
1011
718
|
for (let i = 0; i < STUCK_WINDOW_SIZE - 1; i++) {
|
|
1012
|
-
await orchestrator.advance();
|
|
719
|
+
await f.orchestrator.advance();
|
|
1013
720
|
}
|
|
1014
721
|
|
|
1015
|
-
const stopped = await orchestrator.stop("user-request");
|
|
722
|
+
const stopped = await f.orchestrator.stop("user-request");
|
|
1016
723
|
assert.equal(stopped.kind, "stopped");
|
|
1017
724
|
|
|
1018
|
-
|
|
1019
|
-
const next = await orchestrator.advance();
|
|
725
|
+
const next = await f.orchestrator.advance();
|
|
1020
726
|
assert.equal(next.kind, "advanced");
|
|
1021
727
|
});
|
|
1022
728
|
|
|
1023
|
-
test("stuck-loop: journal records the stuck-loop reason on advance-blocked", async () => {
|
|
1024
|
-
const
|
|
1025
|
-
|
|
729
|
+
test("stuck-loop: journal records the stuck-loop reason on advance-blocked", async (t) => {
|
|
730
|
+
const f = makeFixture();
|
|
731
|
+
t.after(() => f.cleanup());
|
|
1026
732
|
|
|
1027
733
|
for (let i = 0; i < STUCK_WINDOW_SIZE; i++) {
|
|
1028
|
-
await orchestrator.advance();
|
|
734
|
+
await f.orchestrator.advance();
|
|
1029
735
|
}
|
|
1030
736
|
|
|
1031
|
-
|
|
737
|
+
const stuckEntry = queryJournal(f.base).find(
|
|
738
|
+
(e) => {
|
|
739
|
+
const reason = (e.data as Record<string, unknown> | undefined)?.reason;
|
|
740
|
+
return typeof reason === "string" && reason.startsWith("stuck-loop:");
|
|
741
|
+
},
|
|
742
|
+
);
|
|
743
|
+
assert.ok(stuckEntry, "journal must record an advance-blocked entry with the stuck-loop reason");
|
|
744
|
+
assert.ok(f.journalNames().includes("advance-blocked"));
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
748
|
+
// Recovery path: a lock held by another process throws inside advance() and is
|
|
749
|
+
// routed through the REAL classifyFailure → result mapping + notifications.
|
|
750
|
+
// We force the throw by acquiring the lock under a different PID (writing a
|
|
751
|
+
// foreign-PID lockfile is not portable, so we drive the deterministic-stop
|
|
752
|
+
// classification via a fixture whose runtimeBasePath has no valid lock).
|
|
753
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
754
|
+
|
|
755
|
+
test("advance() routes a lost-lock error through recovery and journals an outcome", async (t) => {
|
|
756
|
+
const f = makeFixture();
|
|
757
|
+
t.after(() => f.cleanup());
|
|
758
|
+
|
|
759
|
+
// Release the lock so ensureLockOwnership() sees missing-metadata and throws,
|
|
760
|
+
// exercising the catch → classifyAndRecover → result-mapping branch.
|
|
761
|
+
releaseSessionLock(f.base);
|
|
762
|
+
// Remove the lockfile artifact so getSessionLockStatus returns !valid.
|
|
763
|
+
try { rmSync(join(f.base, ".gsd", "auto.lock"), { force: true }); } catch { /* */ }
|
|
764
|
+
try { rmSync(join(f.base, ".gsd.lock"), { recursive: true, force: true }); } catch { /* */ }
|
|
765
|
+
|
|
766
|
+
const result = await f.orchestrator.advance();
|
|
767
|
+
|
|
768
|
+
// classifyFailure maps a generic Error to a recovery action; the orchestrator
|
|
769
|
+
// surfaces it as paused/stopped/error and journals the corresponding event.
|
|
770
|
+
assert.ok(["paused", "stopped", "error"].includes(result.kind), `unexpected kind ${result.kind}`);
|
|
771
|
+
const names = f.journalNames();
|
|
772
|
+
assert.ok(
|
|
773
|
+
names.includes("advance-paused") || names.includes("advance-stopped") || names.includes("advance-error"),
|
|
774
|
+
"recovery must journal an advance-paused/stopped/error event",
|
|
775
|
+
);
|
|
1032
776
|
});
|
|
1033
777
|
|
|
1034
|
-
//
|
|
778
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
779
|
+
// closeout regression: live-base resolver after worktree cleanup
|
|
780
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1035
781
|
|
|
1036
|
-
test("
|
|
782
|
+
test("live orchestrator base resolver prefers live project root after worktree cleanup", (t) => {
|
|
1037
783
|
const projectRoot = mkdtempSync(join(tmpdir(), "gsd-orch-root-"));
|
|
1038
784
|
const staleWorktreeRoot = join(projectRoot, ".gsd", "worktrees", "M002");
|
|
1039
785
|
mkdirSync(join(staleWorktreeRoot, ".bg-shell"), { recursive: true });
|
|
@@ -1050,7 +796,7 @@ test("wired orchestrator base resolver prefers live project root after worktree
|
|
|
1050
796
|
);
|
|
1051
797
|
});
|
|
1052
798
|
|
|
1053
|
-
test("
|
|
799
|
+
test("live orchestrator base resolver keeps a captured active git worktree", (t) => {
|
|
1054
800
|
const projectRoot = mkdtempSync(join(tmpdir(), "gsd-orch-worktree-"));
|
|
1055
801
|
const worktreeRoot = join(projectRoot, ".gsd", "worktrees", "M003");
|
|
1056
802
|
mkdirSync(worktreeRoot, { recursive: true });
|
|
@@ -1066,14 +812,14 @@ test("wired orchestrator base resolver keeps a captured active git worktree", (t
|
|
|
1066
812
|
);
|
|
1067
813
|
});
|
|
1068
814
|
|
|
1069
|
-
//
|
|
815
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
816
|
+
// Dispatch-decision parity (#5789) — formerly the createWiredDispatchAdapter
|
|
817
|
+
// tests. These exercise the exported pure decideOrchestratorDispatch helper.
|
|
818
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1070
819
|
|
|
1071
|
-
test("
|
|
820
|
+
test("decideOrchestratorDispatch forwards session-derived dispatch inputs identically to runDispatch", async () => {
|
|
1072
821
|
const stateSnapshot = makeState();
|
|
1073
822
|
|
|
1074
|
-
// Install a capturing registry so we observe the DispatchContext both code paths
|
|
1075
|
-
// build, and force a deterministic dispatch action so the parity assertion is
|
|
1076
|
-
// about *inputs*, not rule evaluation.
|
|
1077
823
|
const captured: DispatchContext[] = [];
|
|
1078
824
|
const captureRule: UnifiedRule = {
|
|
1079
825
|
name: "test-capture",
|
|
@@ -1093,7 +839,6 @@ test("wired DispatchAdapter forwards session-derived dispatch inputs identically
|
|
|
1093
839
|
setRegistry(new RuleRegistry([captureRule]));
|
|
1094
840
|
|
|
1095
841
|
try {
|
|
1096
|
-
// Mock ExtensionContext + ExtensionAPI with the surface the wired adapter touches.
|
|
1097
842
|
const fakeModelRegistry = {
|
|
1098
843
|
getAll: () => [],
|
|
1099
844
|
getProviderAuthMode: (_provider: string) => "apiKey" as const,
|
|
@@ -1105,30 +850,28 @@ test("wired DispatchAdapter forwards session-derived dispatch inputs identically
|
|
|
1105
850
|
contextWindow: 200_000,
|
|
1106
851
|
},
|
|
1107
852
|
modelRegistry: fakeModelRegistry,
|
|
1108
|
-
} as
|
|
853
|
+
} as never;
|
|
1109
854
|
const pi = {
|
|
1110
855
|
getActiveTools: () => ["read_file", "write_file"],
|
|
1111
|
-
} as
|
|
856
|
+
} as never;
|
|
1112
857
|
const basePath = "/tmp/parity-fixture";
|
|
1113
858
|
|
|
1114
|
-
// Path A —
|
|
1115
|
-
const
|
|
1116
|
-
const adapterResult = await adapter.decideNextUnit({ stateSnapshot });
|
|
859
|
+
// Path A — the orchestrator's pure dispatch decision.
|
|
860
|
+
const adapterResult = await decideOrchestratorDispatch(ctx, pi, basePath, undefined, { stateSnapshot });
|
|
1117
861
|
|
|
1118
862
|
// Path B — direct resolveDispatch call mirroring phases.ts:runDispatch.
|
|
1119
|
-
|
|
1120
|
-
const
|
|
1121
|
-
const
|
|
1122
|
-
|
|
1123
|
-
? ctx.modelRegistry.getProviderAuthMode(provider)
|
|
863
|
+
const prefs = undefined;
|
|
864
|
+
const provider = (ctx as { model?: { provider?: string } }).model?.provider;
|
|
865
|
+
const authMode = provider && typeof fakeModelRegistry.getProviderAuthMode === "function"
|
|
866
|
+
? fakeModelRegistry.getProviderAuthMode(provider)
|
|
1124
867
|
: undefined;
|
|
1125
|
-
const activeTools =
|
|
868
|
+
const activeTools = ["read_file", "write_file"];
|
|
1126
869
|
const structuredQuestionsAvailable: "true" | "false" =
|
|
1127
870
|
prefs !== undefined && (prefs as { planning_depth?: string }).planning_depth === "deep"
|
|
1128
871
|
? "false"
|
|
1129
872
|
: supportsStructuredQuestions(activeTools, {
|
|
1130
873
|
authMode,
|
|
1131
|
-
baseUrl: ctx.model?.baseUrl,
|
|
874
|
+
baseUrl: (ctx as { model?: { baseUrl?: string } }).model?.baseUrl,
|
|
1132
875
|
})
|
|
1133
876
|
? "true"
|
|
1134
877
|
: "false";
|
|
@@ -1140,17 +883,15 @@ test("wired DispatchAdapter forwards session-derived dispatch inputs identically
|
|
|
1140
883
|
state: stateSnapshot,
|
|
1141
884
|
prefs,
|
|
1142
885
|
structuredQuestionsAvailable,
|
|
1143
|
-
sessionContextWindow:
|
|
1144
|
-
sessionProvider:
|
|
1145
|
-
modelRegistry:
|
|
886
|
+
sessionContextWindow: 200_000,
|
|
887
|
+
sessionProvider: "anthropic",
|
|
888
|
+
modelRegistry: fakeModelRegistry,
|
|
1146
889
|
};
|
|
1147
890
|
const directAction = await resolveDispatch(builtDirectCtx);
|
|
1148
891
|
|
|
1149
|
-
// Two contexts captured: one per resolveDispatch call.
|
|
1150
892
|
assert.equal(captured.length, 2, "expected two captured dispatch contexts");
|
|
1151
893
|
const [adapterCtx, directCtx] = captured;
|
|
1152
894
|
|
|
1153
|
-
// Parity assertion: session-derived fields are identical.
|
|
1154
895
|
assert.equal(adapterCtx.structuredQuestionsAvailable, directCtx.structuredQuestionsAvailable);
|
|
1155
896
|
assert.equal(adapterCtx.sessionContextWindow, directCtx.sessionContextWindow);
|
|
1156
897
|
assert.equal(adapterCtx.sessionProvider, directCtx.sessionProvider);
|
|
@@ -1159,7 +900,6 @@ test("wired DispatchAdapter forwards session-derived dispatch inputs identically
|
|
|
1159
900
|
assert.equal(adapterCtx.mid, directCtx.mid);
|
|
1160
901
|
assert.equal(adapterCtx.midTitle, directCtx.midTitle);
|
|
1161
902
|
|
|
1162
|
-
// Dispatch action equality: both flows reach the same dispatch decision.
|
|
1163
903
|
if (!adapterResult || !("unitType" in adapterResult)) {
|
|
1164
904
|
assert.fail("expected adapter result to be a dispatch decision");
|
|
1165
905
|
}
|
|
@@ -1177,7 +917,7 @@ test("wired DispatchAdapter forwards session-derived dispatch inputs identically
|
|
|
1177
917
|
}
|
|
1178
918
|
});
|
|
1179
919
|
|
|
1180
|
-
test("
|
|
920
|
+
test("decideOrchestratorDispatch prefers caller-supplied dispatch inputs over ctx-derived values", async () => {
|
|
1181
921
|
const stateSnapshot = makeState();
|
|
1182
922
|
const captured: DispatchContext[] = [];
|
|
1183
923
|
const captureRule: UnifiedRule = {
|
|
@@ -1213,14 +953,11 @@ test("wired DispatchAdapter prefers caller-supplied dispatch inputs over ctx-der
|
|
|
1213
953
|
contextWindow: 200_000,
|
|
1214
954
|
},
|
|
1215
955
|
modelRegistry: ctxModelRegistry,
|
|
1216
|
-
} as
|
|
1217
|
-
const pi = {
|
|
1218
|
-
|
|
1219
|
-
} as any;
|
|
1220
|
-
const adapter = createWiredDispatchAdapter(ctx, pi, "/tmp/parity-fixture");
|
|
1221
|
-
const session = { basePath: "/tmp/session-fixture" } as any;
|
|
956
|
+
} as never;
|
|
957
|
+
const pi = { getActiveTools: () => [] } as never;
|
|
958
|
+
const session = { basePath: "/tmp/session-fixture" } as never;
|
|
1222
959
|
|
|
1223
|
-
const result = await
|
|
960
|
+
const result = await decideOrchestratorDispatch(ctx, pi, "/tmp/parity-fixture", undefined, {
|
|
1224
961
|
stateSnapshot,
|
|
1225
962
|
session,
|
|
1226
963
|
structuredQuestionsAvailable: "true",
|
|
@@ -1242,7 +979,7 @@ test("wired DispatchAdapter prefers caller-supplied dispatch inputs over ctx-der
|
|
|
1242
979
|
}
|
|
1243
980
|
});
|
|
1244
981
|
|
|
1245
|
-
test("
|
|
982
|
+
test("decideOrchestratorDispatch forwards constructor session when advance input omits session", async () => {
|
|
1246
983
|
const stateSnapshot = makeState();
|
|
1247
984
|
const captured: DispatchContext[] = [];
|
|
1248
985
|
const captureRule: UnifiedRule = {
|
|
@@ -1263,16 +1000,15 @@ test("wired DispatchAdapter forwards constructor session when advance input omit
|
|
|
1263
1000
|
setRegistry(new RuleRegistry([captureRule]));
|
|
1264
1001
|
|
|
1265
1002
|
try {
|
|
1266
|
-
const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as
|
|
1267
|
-
const pi = { getActiveTools: () => [] } as
|
|
1003
|
+
const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as never;
|
|
1004
|
+
const pi = { getActiveTools: () => [] } as never;
|
|
1268
1005
|
const session = {
|
|
1269
1006
|
basePath: "/tmp/worktree-fixture",
|
|
1270
1007
|
originalBasePath: "/tmp/project-fixture",
|
|
1271
1008
|
currentMilestoneId: "M001",
|
|
1272
|
-
} as
|
|
1273
|
-
const adapter = createWiredDispatchAdapter(ctx, pi, "/tmp/project-fixture", session);
|
|
1009
|
+
} as never;
|
|
1274
1010
|
|
|
1275
|
-
const result = await
|
|
1011
|
+
const result = await decideOrchestratorDispatch(ctx, pi, "/tmp/project-fixture", session, { stateSnapshot });
|
|
1276
1012
|
|
|
1277
1013
|
assert.ok(result);
|
|
1278
1014
|
assert.equal(captured.length, 1, "expected one captured dispatch context");
|
|
@@ -1283,7 +1019,7 @@ test("wired DispatchAdapter forwards constructor session when advance input omit
|
|
|
1283
1019
|
}
|
|
1284
1020
|
});
|
|
1285
1021
|
|
|
1286
|
-
test("
|
|
1022
|
+
test("decideOrchestratorDispatch adopts next active milestone after the session milestone is closed", async (t) => {
|
|
1287
1023
|
const base = mkdtempSync(join(tmpdir(), "gsd-orchestrator-milestone-adopt-"));
|
|
1288
1024
|
t.after(() => rmSync(base, { recursive: true, force: true }));
|
|
1289
1025
|
|
|
@@ -1314,28 +1050,27 @@ test("wired DispatchAdapter adopts next active milestone after the session miles
|
|
|
1314
1050
|
setRegistry(new RuleRegistry([captureRule]));
|
|
1315
1051
|
|
|
1316
1052
|
try {
|
|
1317
|
-
const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as
|
|
1318
|
-
const pi = { getActiveTools: () => [] } as
|
|
1053
|
+
const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as never;
|
|
1054
|
+
const pi = { getActiveTools: () => [] } as never;
|
|
1319
1055
|
const session = {
|
|
1320
1056
|
basePath: base,
|
|
1321
1057
|
originalBasePath: base,
|
|
1322
1058
|
currentMilestoneId: "M001",
|
|
1323
|
-
} as
|
|
1324
|
-
const adapter = createWiredDispatchAdapter(ctx, pi, base, session);
|
|
1059
|
+
} as never;
|
|
1325
1060
|
|
|
1326
|
-
const result = await
|
|
1061
|
+
const result = await decideOrchestratorDispatch(ctx, pi, base, session, { stateSnapshot });
|
|
1327
1062
|
|
|
1328
1063
|
assert.ok(result);
|
|
1329
|
-
if (!("unitType" in result)) assert.fail(`expected dispatch decision, got ${JSON.stringify(result)}`);
|
|
1064
|
+
if (!result || !("unitType" in result)) assert.fail(`expected dispatch decision, got ${JSON.stringify(result)}`);
|
|
1330
1065
|
assert.equal(result.unitId, "M002/S01/T01");
|
|
1331
|
-
assert.equal(session.currentMilestoneId, "M002");
|
|
1066
|
+
assert.equal((session as { currentMilestoneId: string }).currentMilestoneId, "M002");
|
|
1332
1067
|
assert.equal(captured[0]?.session?.currentMilestoneId, "M002");
|
|
1333
1068
|
} finally {
|
|
1334
1069
|
resetRegistry();
|
|
1335
1070
|
}
|
|
1336
1071
|
});
|
|
1337
1072
|
|
|
1338
|
-
test("
|
|
1073
|
+
test("decideOrchestratorDispatch keeps blocking stale milestone worktree scope", async (t) => {
|
|
1339
1074
|
const base = mkdtempSync(join(tmpdir(), "gsd-orchestrator-worktree-block-"));
|
|
1340
1075
|
t.after(() => rmSync(base, { recursive: true, force: true }));
|
|
1341
1076
|
|
|
@@ -1349,16 +1084,15 @@ test("wired DispatchAdapter keeps blocking stale milestone worktree scope", asyn
|
|
|
1349
1084
|
};
|
|
1350
1085
|
const worktreePath = join(base, ".gsd", "worktrees", "M001");
|
|
1351
1086
|
mkdirSync(worktreePath, { recursive: true });
|
|
1352
|
-
const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as
|
|
1353
|
-
const pi = { getActiveTools: () => [] } as
|
|
1087
|
+
const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as never;
|
|
1088
|
+
const pi = { getActiveTools: () => [] } as never;
|
|
1354
1089
|
const session = {
|
|
1355
1090
|
basePath: worktreePath,
|
|
1356
1091
|
originalBasePath: base,
|
|
1357
1092
|
currentMilestoneId: "M001",
|
|
1358
|
-
} as
|
|
1359
|
-
const adapter = createWiredDispatchAdapter(ctx, pi, base, session);
|
|
1093
|
+
} as never;
|
|
1360
1094
|
|
|
1361
|
-
const result = await
|
|
1095
|
+
const result = await decideOrchestratorDispatch(ctx, pi, base, session, { stateSnapshot });
|
|
1362
1096
|
|
|
1363
1097
|
assert.deepEqual(result, {
|
|
1364
1098
|
kind: "blocked",
|
|
@@ -1366,13 +1100,13 @@ test("wired DispatchAdapter keeps blocking stale milestone worktree scope", asyn
|
|
|
1366
1100
|
'Dispatch milestone mismatch: context mid "M002" does not match session.currentMilestoneId "M001". The active worktree/session and derived project state disagree; recover, park, or discard the stranded milestone before continuing.',
|
|
1367
1101
|
action: "pause",
|
|
1368
1102
|
});
|
|
1369
|
-
assert.equal(session.currentMilestoneId, "M001");
|
|
1103
|
+
assert.equal((session as { currentMilestoneId: string }).currentMilestoneId, "M001");
|
|
1370
1104
|
});
|
|
1371
1105
|
|
|
1372
|
-
test("
|
|
1106
|
+
test("decideOrchestratorDispatch replays pending verification retry dispatch", async () => {
|
|
1373
1107
|
const stateSnapshot = makeState();
|
|
1374
|
-
const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as
|
|
1375
|
-
const pi = { getActiveTools: () => [] } as
|
|
1108
|
+
const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as never;
|
|
1109
|
+
const pi = { getActiveTools: () => [] } as never;
|
|
1376
1110
|
const session = {
|
|
1377
1111
|
basePath: "/tmp/worktree-fixture",
|
|
1378
1112
|
pendingOrchestrationDispatch: null,
|
|
@@ -1385,22 +1119,25 @@ test("wired DispatchAdapter replays pending verification retry dispatch", async
|
|
|
1385
1119
|
mid: "M004",
|
|
1386
1120
|
midTitle: "Milestone 4",
|
|
1387
1121
|
},
|
|
1388
|
-
} as
|
|
1389
|
-
const adapter = createWiredDispatchAdapter(ctx, pi, "/tmp/project-fixture", session);
|
|
1122
|
+
} as never;
|
|
1390
1123
|
|
|
1391
|
-
const result = await
|
|
1124
|
+
const result = await decideOrchestratorDispatch(ctx, pi, "/tmp/project-fixture", session, { stateSnapshot });
|
|
1392
1125
|
|
|
1393
1126
|
assert.ok(result);
|
|
1394
|
-
if (!("unitType" in result)) assert.fail("expected dispatch decision");
|
|
1127
|
+
if (!result || !("unitType" in result)) assert.fail("expected dispatch decision");
|
|
1395
1128
|
assert.equal(result.unitType, "complete-slice");
|
|
1396
1129
|
assert.equal(result.unitId, "M004/S01");
|
|
1397
1130
|
assert.equal(result.reason, "verification-retry");
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1131
|
+
const sess = session as {
|
|
1132
|
+
pendingVerificationRetryDispatch: unknown;
|
|
1133
|
+
pendingOrchestrationDispatch: { prompt?: string; state?: unknown } | null;
|
|
1134
|
+
};
|
|
1135
|
+
assert.equal(sess.pendingVerificationRetryDispatch, null);
|
|
1136
|
+
assert.equal(sess.pendingOrchestrationDispatch?.prompt, "repair slice closeout");
|
|
1137
|
+
assert.equal(sess.pendingOrchestrationDispatch?.state, stateSnapshot);
|
|
1401
1138
|
});
|
|
1402
1139
|
|
|
1403
|
-
test("
|
|
1140
|
+
test("decideOrchestratorDispatch clears verification retry state when skipping an already closed retry dispatch", async () => {
|
|
1404
1141
|
const stateSnapshot = makeState();
|
|
1405
1142
|
const base = mkdtempSync(join(tmpdir(), "gsd-orchestrator-closed-retry-"));
|
|
1406
1143
|
|
|
@@ -1425,8 +1162,8 @@ test("wired DispatchAdapter clears verification retry state when skipping an alr
|
|
|
1425
1162
|
};
|
|
1426
1163
|
setRegistry(new RuleRegistry([retryRule]));
|
|
1427
1164
|
|
|
1428
|
-
const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as
|
|
1429
|
-
const pi = { getActiveTools: () => [] } as
|
|
1165
|
+
const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as never;
|
|
1166
|
+
const pi = { getActiveTools: () => [] } as never;
|
|
1430
1167
|
const session = {
|
|
1431
1168
|
basePath: base,
|
|
1432
1169
|
pendingOrchestrationDispatch: { stale: true },
|
|
@@ -1435,17 +1172,17 @@ test("wired DispatchAdapter clears verification retry state when skipping an alr
|
|
|
1435
1172
|
failureContext: "artifact missing",
|
|
1436
1173
|
attempt: 1,
|
|
1437
1174
|
},
|
|
1438
|
-
} as
|
|
1439
|
-
const adapter = createWiredDispatchAdapter(ctx, pi, base, session);
|
|
1175
|
+
} as never;
|
|
1440
1176
|
|
|
1441
|
-
const result = await
|
|
1177
|
+
const result = await decideOrchestratorDispatch(ctx, pi, base, session, { stateSnapshot });
|
|
1442
1178
|
|
|
1443
1179
|
assert.deepEqual(result, {
|
|
1444
1180
|
kind: "skipped",
|
|
1445
1181
|
reason: "execute-task M001/S01/T01 is already complete",
|
|
1446
1182
|
});
|
|
1447
|
-
|
|
1448
|
-
assert.equal(
|
|
1183
|
+
const sess = session as { pendingVerificationRetry: unknown; pendingOrchestrationDispatch: unknown };
|
|
1184
|
+
assert.equal(sess.pendingVerificationRetry, null);
|
|
1185
|
+
assert.equal(sess.pendingOrchestrationDispatch, null);
|
|
1449
1186
|
} finally {
|
|
1450
1187
|
resetRegistry();
|
|
1451
1188
|
closeDatabase();
|
|
@@ -1453,7 +1190,7 @@ test("wired DispatchAdapter clears verification retry state when skipping an alr
|
|
|
1453
1190
|
}
|
|
1454
1191
|
});
|
|
1455
1192
|
|
|
1456
|
-
test("
|
|
1193
|
+
test("decideOrchestratorDispatch preserves stop reason as a blocked decision", async () => {
|
|
1457
1194
|
const stateSnapshot = makeState();
|
|
1458
1195
|
const stopRule: UnifiedRule = {
|
|
1459
1196
|
name: "test-stop",
|
|
@@ -1469,11 +1206,10 @@ test("wired DispatchAdapter preserves stop reason as a blocked decision", async
|
|
|
1469
1206
|
setRegistry(new RuleRegistry([stopRule]));
|
|
1470
1207
|
|
|
1471
1208
|
try {
|
|
1472
|
-
const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as
|
|
1473
|
-
const pi = { getActiveTools: () => [] } as
|
|
1474
|
-
const adapter = createWiredDispatchAdapter(ctx, pi, "/tmp/parity-fixture");
|
|
1209
|
+
const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as never;
|
|
1210
|
+
const pi = { getActiveTools: () => [] } as never;
|
|
1475
1211
|
|
|
1476
|
-
const result = await
|
|
1212
|
+
const result = await decideOrchestratorDispatch(ctx, pi, "/tmp/parity-fixture", undefined, { stateSnapshot });
|
|
1477
1213
|
|
|
1478
1214
|
assert.deepEqual(result, {
|
|
1479
1215
|
kind: "blocked",
|
|
@@ -1485,7 +1221,7 @@ test("wired DispatchAdapter preserves stop reason as a blocked decision", async
|
|
|
1485
1221
|
}
|
|
1486
1222
|
});
|
|
1487
1223
|
|
|
1488
|
-
test("
|
|
1224
|
+
test("decideOrchestratorDispatch preserves dispatch skip instead of collapsing it to no remaining units", async () => {
|
|
1489
1225
|
const stateSnapshot = makeState();
|
|
1490
1226
|
const skipRule: UnifiedRule = {
|
|
1491
1227
|
name: "test-skip-gate",
|
|
@@ -1500,11 +1236,10 @@ test("wired DispatchAdapter preserves dispatch skip instead of collapsing it to
|
|
|
1500
1236
|
setRegistry(new RuleRegistry([skipRule]));
|
|
1501
1237
|
|
|
1502
1238
|
try {
|
|
1503
|
-
const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as
|
|
1504
|
-
const pi = { getActiveTools: () => [] } as
|
|
1505
|
-
const adapter = createWiredDispatchAdapter(ctx, pi, "/tmp/parity-fixture");
|
|
1239
|
+
const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as never;
|
|
1240
|
+
const pi = { getActiveTools: () => [] } as never;
|
|
1506
1241
|
|
|
1507
|
-
const result = await
|
|
1242
|
+
const result = await decideOrchestratorDispatch(ctx, pi, "/tmp/parity-fixture", undefined, { stateSnapshot });
|
|
1508
1243
|
|
|
1509
1244
|
assert.deepEqual(result, {
|
|
1510
1245
|
kind: "skipped",
|