@pellux/goodvibes-sdk 0.37.2 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contracts/artifacts/operator-contract.json +1 -1
- package/dist/events/communication.d.ts +21 -1
- package/dist/events/communication.d.ts.map +1 -1
- package/dist/platform/agents/message-bus-core.d.ts +7 -0
- package/dist/platform/agents/message-bus-core.d.ts.map +1 -1
- package/dist/platform/agents/message-bus-core.js +2 -1
- package/dist/platform/agents/orchestrator-runner.d.ts +37 -1
- package/dist/platform/agents/orchestrator-runner.d.ts.map +1 -1
- package/dist/platform/agents/orchestrator-runner.js +158 -7
- package/dist/platform/agents/orchestrator.d.ts +40 -0
- package/dist/platform/agents/orchestrator.d.ts.map +1 -1
- package/dist/platform/agents/orchestrator.js +29 -0
- package/dist/platform/agents/turn-knowledge-injection.d.ts +107 -0
- package/dist/platform/agents/turn-knowledge-injection.d.ts.map +1 -0
- package/dist/platform/agents/turn-knowledge-injection.js +152 -0
- package/dist/platform/agents/wrfc-controller.d.ts +23 -0
- package/dist/platform/agents/wrfc-controller.d.ts.map +1 -1
- package/dist/platform/agents/wrfc-controller.js +55 -0
- package/dist/platform/config/manager.d.ts +9 -0
- package/dist/platform/config/manager.d.ts.map +1 -1
- package/dist/platform/config/manager.js +15 -0
- package/dist/platform/core/adaptive-planner.d.ts +50 -0
- package/dist/platform/core/adaptive-planner.d.ts.map +1 -1
- package/dist/platform/core/adaptive-planner.js +49 -0
- package/dist/platform/core/index.d.ts +1 -0
- package/dist/platform/core/index.d.ts.map +1 -1
- package/dist/platform/core/index.js +1 -0
- package/dist/platform/core/orchestrator-runtime.d.ts +11 -0
- package/dist/platform/core/orchestrator-runtime.d.ts.map +1 -1
- package/dist/platform/core/orchestrator-turn-loop.d.ts +28 -1
- package/dist/platform/core/orchestrator-turn-loop.d.ts.map +1 -1
- package/dist/platform/core/orchestrator-turn-loop.js +115 -3
- package/dist/platform/core/orchestrator.d.ts +61 -0
- package/dist/platform/core/orchestrator.d.ts.map +1 -1
- package/dist/platform/core/orchestrator.js +74 -1
- package/dist/platform/core/plan-proposal.d.ts +165 -0
- package/dist/platform/core/plan-proposal.d.ts.map +1 -0
- package/dist/platform/core/plan-proposal.js +296 -0
- package/dist/platform/knowledge/knowledge-api.d.ts +26 -0
- package/dist/platform/knowledge/knowledge-api.d.ts.map +1 -1
- package/dist/platform/knowledge/knowledge-api.js +11 -0
- package/dist/platform/orchestration/budget.d.ts +22 -0
- package/dist/platform/orchestration/budget.d.ts.map +1 -0
- package/dist/platform/orchestration/budget.js +35 -0
- package/dist/platform/orchestration/cancellation.d.ts +31 -0
- package/dist/platform/orchestration/cancellation.d.ts.map +1 -0
- package/dist/platform/orchestration/cancellation.js +25 -0
- package/dist/platform/orchestration/controller-compat.d.ts +32 -0
- package/dist/platform/orchestration/controller-compat.d.ts.map +1 -0
- package/dist/platform/orchestration/controller-compat.js +13 -0
- package/dist/platform/orchestration/dirty-guard.d.ts +32 -0
- package/dist/platform/orchestration/dirty-guard.d.ts.map +1 -0
- package/dist/platform/orchestration/dirty-guard.js +147 -0
- package/dist/platform/orchestration/engine.d.ts +57 -0
- package/dist/platform/orchestration/engine.d.ts.map +1 -0
- package/dist/platform/orchestration/engine.js +418 -0
- package/dist/platform/orchestration/index.d.ts +18 -0
- package/dist/platform/orchestration/index.d.ts.map +1 -0
- package/dist/platform/orchestration/index.js +10 -0
- package/dist/platform/orchestration/persistence.d.ts +24 -0
- package/dist/platform/orchestration/persistence.d.ts.map +1 -0
- package/dist/platform/orchestration/persistence.js +176 -0
- package/dist/platform/orchestration/phase-runner.d.ts +87 -0
- package/dist/platform/orchestration/phase-runner.d.ts.map +1 -0
- package/dist/platform/orchestration/phase-runner.js +274 -0
- package/dist/platform/orchestration/scheduler.d.ts +45 -0
- package/dist/platform/orchestration/scheduler.d.ts.map +1 -0
- package/dist/platform/orchestration/scheduler.js +57 -0
- package/dist/platform/orchestration/types.d.ts +250 -0
- package/dist/platform/orchestration/types.d.ts.map +1 -0
- package/dist/platform/orchestration/types.js +15 -0
- package/dist/platform/runtime/emitters/communication.d.ts +11 -0
- package/dist/platform/runtime/emitters/communication.d.ts.map +1 -1
- package/dist/platform/runtime/emitters/communication.js +9 -0
- package/dist/platform/runtime/feature-flags/flags.d.ts.map +1 -1
- package/dist/platform/runtime/feature-flags/flags.js +17 -0
- package/dist/platform/runtime/fleet/adapters/agent.d.ts +56 -0
- package/dist/platform/runtime/fleet/adapters/agent.d.ts.map +1 -0
- package/dist/platform/runtime/fleet/adapters/agent.js +143 -0
- package/dist/platform/runtime/fleet/adapters/automation.d.ts +30 -0
- package/dist/platform/runtime/fleet/adapters/automation.d.ts.map +1 -0
- package/dist/platform/runtime/fleet/adapters/automation.js +41 -0
- package/dist/platform/runtime/fleet/adapters/background-process.d.ts +12 -0
- package/dist/platform/runtime/fleet/adapters/background-process.d.ts.map +1 -0
- package/dist/platform/runtime/fleet/adapters/background-process.js +46 -0
- package/dist/platform/runtime/fleet/adapters/code-index.d.ts +21 -0
- package/dist/platform/runtime/fleet/adapters/code-index.d.ts.map +1 -0
- package/dist/platform/runtime/fleet/adapters/code-index.js +44 -0
- package/dist/platform/runtime/fleet/adapters/orchestration.d.ts +47 -0
- package/dist/platform/runtime/fleet/adapters/orchestration.d.ts.map +1 -0
- package/dist/platform/runtime/fleet/adapters/orchestration.js +225 -0
- package/dist/platform/runtime/fleet/adapters/schedule.d.ts +14 -0
- package/dist/platform/runtime/fleet/adapters/schedule.d.ts.map +1 -0
- package/dist/platform/runtime/fleet/adapters/schedule.js +28 -0
- package/dist/platform/runtime/fleet/adapters/trigger.d.ts +12 -0
- package/dist/platform/runtime/fleet/adapters/trigger.d.ts.map +1 -0
- package/dist/platform/runtime/fleet/adapters/trigger.js +22 -0
- package/dist/platform/runtime/fleet/adapters/watcher.d.ts +13 -0
- package/dist/platform/runtime/fleet/adapters/watcher.d.ts.map +1 -0
- package/dist/platform/runtime/fleet/adapters/watcher.js +47 -0
- package/dist/platform/runtime/fleet/adapters/workflow.d.ts +11 -0
- package/dist/platform/runtime/fleet/adapters/workflow.d.ts.map +1 -0
- package/dist/platform/runtime/fleet/adapters/workflow.js +34 -0
- package/dist/platform/runtime/fleet/adapters/wrfc.d.ts +22 -0
- package/dist/platform/runtime/fleet/adapters/wrfc.d.ts.map +1 -0
- package/dist/platform/runtime/fleet/adapters/wrfc.js +228 -0
- package/dist/platform/runtime/fleet/index.d.ts +10 -0
- package/dist/platform/runtime/fleet/index.d.ts.map +1 -0
- package/dist/platform/runtime/fleet/index.js +6 -0
- package/dist/platform/runtime/fleet/registry.d.ts +113 -0
- package/dist/platform/runtime/fleet/registry.d.ts.map +1 -0
- package/dist/platform/runtime/fleet/registry.js +630 -0
- package/dist/platform/runtime/fleet/types.d.ts +199 -0
- package/dist/platform/runtime/fleet/types.d.ts.map +1 -0
- package/dist/platform/runtime/fleet/types.js +2 -0
- package/dist/platform/runtime/runtime-knowledge-api.d.ts +1 -1
- package/dist/platform/runtime/runtime-knowledge-api.d.ts.map +1 -1
- package/dist/platform/runtime/runtime-knowledge-api.js +1 -0
- package/dist/platform/runtime/services.d.ts +46 -0
- package/dist/platform/runtime/services.d.ts.map +1 -1
- package/dist/platform/runtime/services.js +78 -0
- package/dist/platform/state/code-index-chunking.d.ts +49 -0
- package/dist/platform/state/code-index-chunking.d.ts.map +1 -0
- package/dist/platform/state/code-index-chunking.js +111 -0
- package/dist/platform/state/code-index-db.d.ts +53 -0
- package/dist/platform/state/code-index-db.d.ts.map +1 -0
- package/dist/platform/state/code-index-db.js +101 -0
- package/dist/platform/state/code-index-store.d.ts +175 -0
- package/dist/platform/state/code-index-store.d.ts.map +1 -0
- package/dist/platform/state/code-index-store.js +612 -0
- package/dist/platform/state/index.d.ts +4 -2
- package/dist/platform/state/index.d.ts.map +1 -1
- package/dist/platform/state/index.js +2 -1
- package/dist/platform/state/knowledge-injection.d.ts +23 -0
- package/dist/platform/state/knowledge-injection.d.ts.map +1 -1
- package/dist/platform/state/knowledge-injection.js +34 -19
- package/dist/platform/state/memory-vector-store.d.ts +1 -13
- package/dist/platform/state/memory-vector-store.d.ts.map +1 -1
- package/dist/platform/state/memory-vector-store.js +6 -38
- package/dist/platform/state/sqlite-vec-loader.d.ts +25 -0
- package/dist/platform/state/sqlite-vec-loader.d.ts.map +1 -0
- package/dist/platform/state/sqlite-vec-loader.js +43 -0
- package/dist/platform/tools/agent/manager.d.ts +128 -1
- package/dist/platform/tools/agent/manager.d.ts.map +1 -1
- package/dist/platform/tools/agent/manager.js +137 -1
- package/dist/platform/tools/exec/runtime.d.ts.map +1 -1
- package/dist/platform/tools/exec/runtime.js +139 -44
- package/dist/platform/tools/exec/schema.d.ts +2 -0
- package/dist/platform/tools/exec/schema.d.ts.map +1 -1
- package/dist/platform/tools/fetch/runtime.d.ts +7 -0
- package/dist/platform/tools/fetch/runtime.d.ts.map +1 -1
- package/dist/platform/tools/fetch/runtime.js +8 -6
- package/dist/platform/tools/registry.d.ts +9 -3
- package/dist/platform/tools/registry.d.ts.map +1 -1
- package/dist/platform/tools/registry.js +9 -3
- package/dist/platform/types/tools.d.ts +14 -1
- package/dist/platform/types/tools.d.ts.map +1 -1
- package/dist/platform/version.js +1 -1
- package/package.json +17 -9
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/** SDK-owned platform module. This implementation is maintained in goodvibes-sdk. */
|
|
2
|
+
/**
|
|
3
|
+
* Dirty-residue guard (Wave 6, wo-F item 4) — prevents an orchestration
|
|
4
|
+
* engine run's scoped commit from sweeping in uncommitted changes that were
|
|
5
|
+
* ALREADY sitting in the working tree before this engine launched (typically
|
|
6
|
+
* residue left behind by a previously killed run sharing the same
|
|
7
|
+
* `projectRoot`, since phase-runner.ts's WorkItem.touchedPaths accumulates
|
|
8
|
+
* across phases/retries and never resets — see phase-runner.ts runPhase).
|
|
9
|
+
*
|
|
10
|
+
* Mechanism: snapshot every dirty path + a binary-safe content hash at
|
|
11
|
+
* engine-launch time (`snapshotDirtyTree`). At scoped-commit time, a
|
|
12
|
+
* candidate path is excluded only when it was ALREADY dirty at launch AND
|
|
13
|
+
* its content hash is unchanged since launch — i.e. this run never touched
|
|
14
|
+
* it. A path that was clean at launch, or whose hash changed, is included:
|
|
15
|
+
* this run is the one that dirtied (or re-dirtied) it.
|
|
16
|
+
*
|
|
17
|
+
* `snapshotDirtyTree` is deliberately SYNCHRONOUS (Bun.spawnSync, mirroring
|
|
18
|
+
* GitService's own sync helpers — initRepo/isGitRepo/getRepoRoot,
|
|
19
|
+
* platform/git/service.ts) rather than shelling out through simple-git's
|
|
20
|
+
* async API: the engine factory (engine.ts) is a synchronous function that
|
|
21
|
+
* must have a settled snapshot before its first phase can possibly reach a
|
|
22
|
+
* commit, and a promise backed by real child-process I/O cannot be relied
|
|
23
|
+
* on to resolve within a caller's microtask-only wait (subprocess exit is
|
|
24
|
+
* delivered via the event loop's macrotask phases, not microtasks) — a real
|
|
25
|
+
* regression seen against tests that only drain microtasks. One blocking
|
|
26
|
+
* `git status` call at launch (paid once, exactly like GitService.initRepo)
|
|
27
|
+
* avoids that whole class of timing hazard entirely.
|
|
28
|
+
*/
|
|
29
|
+
import { createHash } from 'node:crypto';
|
|
30
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
import { logger } from '../utils/logger.js';
|
|
33
|
+
import { summarizeError } from '../utils/error-display.js';
|
|
34
|
+
/** Dirty paths under this prefix are GoodVibes' own internal state, never a candidate for a scoped commit — excluded from the snapshot so they never pollute the 'dirty-tree-at-launch' event. */
|
|
35
|
+
const INTERNAL_STATE_PREFIX = '.goodvibes/';
|
|
36
|
+
/** Content hash of a file's current working-tree bytes. Binary-safe (raw bytes, not text). Returns null when the path does not exist on disk (a working-tree deletion). */
|
|
37
|
+
export function hashWorkingTreeFile(cwd, relativePath) {
|
|
38
|
+
const absolutePath = join(cwd, relativePath);
|
|
39
|
+
if (!existsSync(absolutePath))
|
|
40
|
+
return null;
|
|
41
|
+
try {
|
|
42
|
+
return createHash('sha256').update(readFileSync(absolutePath)).digest('hex');
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
logger.warn('orchestration dirty-guard: failed to hash working-tree file', {
|
|
46
|
+
path: relativePath,
|
|
47
|
+
error: summarizeError(error),
|
|
48
|
+
});
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Parse `git status --porcelain -z --untracked-files=all` output into a set of
|
|
54
|
+
* paths. The `-z` (NUL-terminated) format is used deliberately because it emits
|
|
55
|
+
* paths VERBATIM: git's default porcelain C-quotes any path containing
|
|
56
|
+
* non-ASCII or special bytes (core.quotePath defaults to true), rendering
|
|
57
|
+
* `café.txt` as `"caf\303\251.txt"`. Keying the launch snapshot on that quoted
|
|
58
|
+
* string while excludeUntouchedLaunchResidue / hashWorkingTreeFile receive the
|
|
59
|
+
* RAW path would silently bypass exclusion (residue leaks into scoped commits)
|
|
60
|
+
* and make the content hash read the wrong (nonexistent, quoted) filename. With
|
|
61
|
+
* `-z` there is no quoting, so snapshot keys match the raw paths used
|
|
62
|
+
* everywhere else.
|
|
63
|
+
*
|
|
64
|
+
* Record shape: each NUL-terminated record is `XY<space><path>`. A rename or
|
|
65
|
+
* copy (X or Y is 'R'/'C') is split across TWO NUL-terminated fields — the
|
|
66
|
+
* destination first, then the origin. We keep the destination (the actual
|
|
67
|
+
* scoped-commit candidate, already in this record) and consume the following
|
|
68
|
+
* origin field so it is never mis-parsed as its own dirty path.
|
|
69
|
+
*/
|
|
70
|
+
function parsePorcelainPaths(output) {
|
|
71
|
+
const paths = new Set();
|
|
72
|
+
const records = output.split('\0');
|
|
73
|
+
for (let i = 0; i < records.length; i++) {
|
|
74
|
+
const record = records[i];
|
|
75
|
+
// Skips the trailing empty field left by the final NUL, plus any record too
|
|
76
|
+
// short to hold a 2-char status pair + separator + a 1-char path.
|
|
77
|
+
if (record.length < 4)
|
|
78
|
+
continue;
|
|
79
|
+
const x = record[0];
|
|
80
|
+
const y = record[1];
|
|
81
|
+
// A rename/copy record is followed by a second NUL-separated field (the
|
|
82
|
+
// origin path). Consume it so the next iteration does not treat it as a
|
|
83
|
+
// standalone status record — done before any early-continue below.
|
|
84
|
+
if (x === 'R' || y === 'R' || x === 'C' || y === 'C')
|
|
85
|
+
i++;
|
|
86
|
+
const path = record.slice(3);
|
|
87
|
+
if (path.startsWith(INTERNAL_STATE_PREFIX))
|
|
88
|
+
continue;
|
|
89
|
+
paths.add(path);
|
|
90
|
+
}
|
|
91
|
+
return paths;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Snapshot every dirty path (staged + unstaged + untracked) in `cwd`'s
|
|
95
|
+
* working tree right now, each with a content hash. Call once at engine
|
|
96
|
+
* launch, before any phase of this run has had a chance to touch anything.
|
|
97
|
+
* Never throws: a git failure (e.g. `cwd` is not a repo) degrades to an
|
|
98
|
+
* empty snapshot, which makes the exclusion guard a no-op (today's
|
|
99
|
+
* behavior) rather than blocking commits.
|
|
100
|
+
*/
|
|
101
|
+
export function snapshotDirtyTree(cwd) {
|
|
102
|
+
try {
|
|
103
|
+
const result = Bun.spawnSync(['git', '-C', cwd, 'status', '--porcelain', '-z', '--untracked-files=all']);
|
|
104
|
+
if (result.exitCode !== 0)
|
|
105
|
+
return new Map();
|
|
106
|
+
const output = new TextDecoder().decode(result.stdout);
|
|
107
|
+
const paths = parsePorcelainPaths(output);
|
|
108
|
+
const snapshot = new Map();
|
|
109
|
+
for (const path of paths)
|
|
110
|
+
snapshot.set(path, hashWorkingTreeFile(cwd, path));
|
|
111
|
+
return snapshot;
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
logger.warn('orchestration dirty-guard: failed to snapshot dirty tree at launch', { error: summarizeError(error) });
|
|
115
|
+
return new Map();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Partition `candidatePaths` into paths safe to include in a scoped commit
|
|
120
|
+
* vs. paths that must be excluded because they are untouched launch-dirty
|
|
121
|
+
* residue. A path is excluded only when BOTH:
|
|
122
|
+
* - it appears in `launchSnapshot` (it was already dirty before this run), AND
|
|
123
|
+
* - its current content hash equals the hash recorded at launch (this run
|
|
124
|
+
* never actually changed it).
|
|
125
|
+
* Everything else is included — a path absent from the snapshot was clean
|
|
126
|
+
* at launch (this run is what dirtied it), and a path whose hash changed
|
|
127
|
+
* was genuinely touched by this run even though it started out dirty.
|
|
128
|
+
*/
|
|
129
|
+
export function excludeUntouchedLaunchResidue(cwd, candidatePaths, launchSnapshot) {
|
|
130
|
+
const included = [];
|
|
131
|
+
const excluded = [];
|
|
132
|
+
for (const path of candidatePaths) {
|
|
133
|
+
if (!launchSnapshot.has(path)) {
|
|
134
|
+
included.push(path);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const hashAtLaunch = launchSnapshot.get(path) ?? null;
|
|
138
|
+
const hashNow = hashWorkingTreeFile(cwd, path);
|
|
139
|
+
if (hashAtLaunch === hashNow) {
|
|
140
|
+
excluded.push(path);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
included.push(path);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { included, excluded };
|
|
147
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** SDK-owned platform module. This implementation is maintained in goodvibes-sdk. */
|
|
2
|
+
import type { PhaseRunnerAgentManagerLike, WrfcWorktreeOps } from './phase-runner.js';
|
|
3
|
+
import { type BudgetCeiling, type OrchestrationEventListener, type Phase, type PhaseResult, type PhaseSpec, type WorkItemSpec, type WorkItemUsage, type Workstream } from './types.js';
|
|
4
|
+
import type { ConfigManager } from '../config/manager.js';
|
|
5
|
+
import type { RuntimeEventBus } from '../runtime/events/index.js';
|
|
6
|
+
export interface OrchestrationEngineDeps {
|
|
7
|
+
readonly agentManager: PhaseRunnerAgentManagerLike;
|
|
8
|
+
readonly configManager: Pick<ConfigManager, 'get' | 'getCategory'>;
|
|
9
|
+
readonly runtimeBus: RuntimeEventBus;
|
|
10
|
+
readonly projectRoot: string;
|
|
11
|
+
readonly sessionId?: string | undefined;
|
|
12
|
+
readonly createWorktree?: (() => WrfcWorktreeOps) | undefined;
|
|
13
|
+
readonly priceUsage?: ((model: string | undefined, usage: WorkItemUsage) => number | null) | undefined;
|
|
14
|
+
readonly skipClaimVerification?: boolean | undefined;
|
|
15
|
+
/** Bounds re-review cycles through a dynamically-inserted fix phase. Default 5 (mirrors WrfcController's default maxFixAttempts). */
|
|
16
|
+
readonly maxPhaseVisits?: number | undefined;
|
|
17
|
+
readonly now?: (() => number) | undefined;
|
|
18
|
+
/** Set false to skip wiring the debounced disk writer (tests that don't want filesystem side effects). Default true. */
|
|
19
|
+
readonly persist?: boolean | undefined;
|
|
20
|
+
}
|
|
21
|
+
export interface CreateWorkstreamInput {
|
|
22
|
+
readonly id?: string | undefined;
|
|
23
|
+
readonly title: string;
|
|
24
|
+
readonly phases: readonly PhaseSpec[];
|
|
25
|
+
readonly items: readonly WorkItemSpec[];
|
|
26
|
+
readonly budget?: BudgetCeiling | undefined;
|
|
27
|
+
}
|
|
28
|
+
export interface OrchestrationEngine {
|
|
29
|
+
createWorkstream(input: CreateWorkstreamInput): Workstream;
|
|
30
|
+
getWorkstream(id: string): Workstream | null;
|
|
31
|
+
listWorkstreams(): Workstream[];
|
|
32
|
+
insertPhase(workstreamId: string, afterOrdinal: number, spec: PhaseSpec): Phase | null;
|
|
33
|
+
/** Begin (or resume ticking) a workstream's pipeline. Idempotent — safe to call on an already-running workstream. */
|
|
34
|
+
start(workstreamId: string): void;
|
|
35
|
+
/** Abort a work item's in-flight agent (if any) and mark it terminally failed. Never affects sibling items. */
|
|
36
|
+
kill(itemId: string): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Replace (or clear, via `undefined`) a workstream's budget ceiling and
|
|
39
|
+
* immediately re-tick it, so any item already sitting in 'blocked-budget'
|
|
40
|
+
* gets reconsidered right away — the only recovery path for that state
|
|
41
|
+
* (see WorkItemState's 'blocked-budget' doc, types.ts). Returns false if
|
|
42
|
+
* the workstream doesn't exist.
|
|
43
|
+
*/
|
|
44
|
+
updateBudget(workstreamId: string, ceiling: BudgetCeiling | undefined): boolean;
|
|
45
|
+
getPhaseResults(workstreamId: string): readonly PhaseResult[];
|
|
46
|
+
serializeWorkstream(workstreamId: string): string | null;
|
|
47
|
+
/** Import a serialized snapshot (a full WorkstreamSnapshot JSON string, see persistence.ts). Refuses to overwrite a non-terminal in-memory workstream unless force=true. */
|
|
48
|
+
importWorkstream(snapshotJson: string, force?: boolean): boolean;
|
|
49
|
+
/** Import + start from the on-disk snapshot for one workstream id. */
|
|
50
|
+
resumeWorkstream(workstreamId: string): boolean;
|
|
51
|
+
/** Import + start every snapshot under .goodvibes/orchestration/. Returns the count resumed. */
|
|
52
|
+
resumeAllFromDisk(): number;
|
|
53
|
+
on(listener: OrchestrationEventListener): () => void;
|
|
54
|
+
dispose(): void;
|
|
55
|
+
}
|
|
56
|
+
export declare function createOrchestrationEngine(deps: OrchestrationEngineDeps): OrchestrationEngine;
|
|
57
|
+
//# sourceMappingURL=engine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../../src/platform/orchestration/engine.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/** SDK-owned platform module. This implementation is maintained in goodvibes-sdk. */
|
|
2
|
+
/**
|
|
3
|
+
* OrchestrationEngine (Wave 4, wo701) — owns Workstream state and drives the
|
|
4
|
+
* pipeline. WRAPS a canned/authored workstream; does not rewrite or touch
|
|
5
|
+
* WrfcController (stage 1 of the 3-stage migration — see controller-compat.ts
|
|
6
|
+
* and the work-order report).
|
|
7
|
+
*
|
|
8
|
+
* The tick loop is reactive, not timer-driven: `start()` runs one tick;
|
|
9
|
+
* every phase-run completion re-runs tick() for its workstream. Because JS
|
|
10
|
+
* is single-threaded, computeClaims()'s synchronous read of in-flight counts
|
|
11
|
+
* is never racing a concurrent claim — items are marked 'in-phase'
|
|
12
|
+
* synchronously before any `await` inside the claim loop, so a re-entrant
|
|
13
|
+
* tick() (triggered by an earlier completion resolving on a later turn of
|
|
14
|
+
* the microtask queue) always sees up-to-date state.
|
|
15
|
+
*/
|
|
16
|
+
import { checkBudget } from './budget.js';
|
|
17
|
+
import { createCancellationRegistry } from './cancellation.js';
|
|
18
|
+
import { runPhase } from './phase-runner.js';
|
|
19
|
+
import { snapshotDirtyTree } from './dirty-guard.js';
|
|
20
|
+
import { deserializeWorkstream as deserializeWorkstreamModel, deserializeWorkstreamSnapshot, attachDebouncedWriter, listSnapshotWorkstreamIds, loadWorkstreamSnapshot, serializeWorkstreamSnapshot, } from './persistence.js';
|
|
21
|
+
import { computeClaims, firstPhase, nextPhaseAfter, reviewPhaseBefore, sortedPhases } from './scheduler.js';
|
|
22
|
+
import { CURRENT_WORKSTREAM_SCHEMA_VERSION, emptyWorkItemUsage, } from './types.js';
|
|
23
|
+
import { logger } from '../utils/logger.js';
|
|
24
|
+
import { summarizeError } from '../utils/error-display.js';
|
|
25
|
+
function generateId(prefix) {
|
|
26
|
+
return `${prefix}-${crypto.randomUUID().slice(0, 8)}`;
|
|
27
|
+
}
|
|
28
|
+
export function createOrchestrationEngine(deps) {
|
|
29
|
+
const now = deps.now ?? (() => Date.now());
|
|
30
|
+
const sessionId = deps.sessionId ?? crypto.randomUUID().slice(0, 8);
|
|
31
|
+
const maxPhaseVisits = deps.maxPhaseVisits ?? 5;
|
|
32
|
+
const workstreams = new Map();
|
|
33
|
+
const completedResults = new Map();
|
|
34
|
+
const cancellation = createCancellationRegistry();
|
|
35
|
+
const listeners = new Set();
|
|
36
|
+
let disposed = false;
|
|
37
|
+
function emit(event) {
|
|
38
|
+
for (const listener of listeners) {
|
|
39
|
+
try {
|
|
40
|
+
listener(event);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
logger.warn('orchestration engine: listener threw', { error: summarizeError(error) });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function on(listener) {
|
|
48
|
+
listeners.add(listener);
|
|
49
|
+
return () => listeners.delete(listener);
|
|
50
|
+
}
|
|
51
|
+
// Dirty-residue guard (Wave 6, wo-F item 4): snapshot the working tree's
|
|
52
|
+
// dirty paths + content hashes ONCE, right at engine launch (synchronous —
|
|
53
|
+
// see dirty-guard.ts's doc comment for why this must not be a promise
|
|
54
|
+
// backed by real subprocess I/O), before any phase of this run has had a
|
|
55
|
+
// chance to touch anything. A previously killed run sharing this same
|
|
56
|
+
// projectRoot can leave uncommitted residue behind; this snapshot is what
|
|
57
|
+
// lets a later scoped commit tell "residue from before this run" apart
|
|
58
|
+
// from "this run's own changes" (see dirty-guard.ts and phase-runner.ts's
|
|
59
|
+
// commitPhaseWork).
|
|
60
|
+
const launchDirtySnapshot = snapshotDirtyTree(deps.projectRoot);
|
|
61
|
+
if (launchDirtySnapshot.size > 0) {
|
|
62
|
+
emit({ type: 'dirty-tree-at-launch', paths: [...launchDirtySnapshot.keys()] });
|
|
63
|
+
}
|
|
64
|
+
function getWorkstream(id) {
|
|
65
|
+
return workstreams.get(id) ?? null;
|
|
66
|
+
}
|
|
67
|
+
function listWorkstreams() {
|
|
68
|
+
return Array.from(workstreams.values());
|
|
69
|
+
}
|
|
70
|
+
function getPhaseResults(workstreamId) {
|
|
71
|
+
return completedResults.get(workstreamId) ?? [];
|
|
72
|
+
}
|
|
73
|
+
const unsubscribeWriter = deps.persist === false
|
|
74
|
+
? () => undefined
|
|
75
|
+
: attachDebouncedWriter(deps.projectRoot, getWorkstream, getPhaseResults, on);
|
|
76
|
+
function buildPhase(spec, ordinal, insertedAt) {
|
|
77
|
+
return {
|
|
78
|
+
id: spec.id ?? generateId('phase'),
|
|
79
|
+
ordinal,
|
|
80
|
+
role: spec.role,
|
|
81
|
+
capacity: spec.capacity,
|
|
82
|
+
gate: spec.gate,
|
|
83
|
+
kind: spec.kind,
|
|
84
|
+
...(insertedAt !== undefined ? { insertedAt } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function buildItem(spec, startPhaseId) {
|
|
88
|
+
return {
|
|
89
|
+
id: spec.id ?? generateId('item'),
|
|
90
|
+
title: spec.title,
|
|
91
|
+
task: spec.task,
|
|
92
|
+
currentPhaseId: startPhaseId,
|
|
93
|
+
state: startPhaseId ? 'pending' : 'passed',
|
|
94
|
+
allAgentIds: [],
|
|
95
|
+
visits: new Map(),
|
|
96
|
+
touchedPaths: [],
|
|
97
|
+
usage: emptyWorkItemUsage(),
|
|
98
|
+
transportRetryCount: 0,
|
|
99
|
+
createdAt: now(),
|
|
100
|
+
...(startPhaseId ? {} : { completedAt: now() }),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function createWorkstream(input) {
|
|
104
|
+
const phases = input.phases.map((spec, index) => buildPhase(spec, index + 1));
|
|
105
|
+
const workstream = {
|
|
106
|
+
id: input.id ?? generateId('ws'),
|
|
107
|
+
title: input.title,
|
|
108
|
+
schemaVersion: CURRENT_WORKSTREAM_SCHEMA_VERSION,
|
|
109
|
+
phases,
|
|
110
|
+
items: [],
|
|
111
|
+
budget: input.budget,
|
|
112
|
+
createdAt: now(),
|
|
113
|
+
};
|
|
114
|
+
const first = firstPhase(workstream)?.id ?? null;
|
|
115
|
+
workstream.items = input.items.map((spec) => buildItem(spec, first));
|
|
116
|
+
workstreams.set(workstream.id, workstream);
|
|
117
|
+
completedResults.set(workstream.id, []);
|
|
118
|
+
return workstream;
|
|
119
|
+
}
|
|
120
|
+
function insertPhase(workstreamId, afterOrdinal, spec) {
|
|
121
|
+
const workstream = workstreams.get(workstreamId);
|
|
122
|
+
if (!workstream)
|
|
123
|
+
return null;
|
|
124
|
+
const following = sortedPhases(workstream).filter((p) => p.ordinal > afterOrdinal);
|
|
125
|
+
const nextOrdinal = following[0]?.ordinal ?? afterOrdinal + 1;
|
|
126
|
+
const ordinal = (afterOrdinal + nextOrdinal) / 2;
|
|
127
|
+
const phase = buildPhase(spec, ordinal, now());
|
|
128
|
+
workstream.phases.push(phase);
|
|
129
|
+
emit({ type: 'phase-inserted', workstreamId, phase });
|
|
130
|
+
return phase;
|
|
131
|
+
}
|
|
132
|
+
function findOrInsertFixPhase(workstream, reviewPhase) {
|
|
133
|
+
const existing = workstream.phases.find((p) => p.kind === 'fix' && reviewPhaseBefore(workstream, p)?.id === reviewPhase.id);
|
|
134
|
+
if (existing)
|
|
135
|
+
return existing;
|
|
136
|
+
return insertPhase(workstream.id, reviewPhase.ordinal, {
|
|
137
|
+
role: 'fixer',
|
|
138
|
+
capacity: reviewPhase.capacity,
|
|
139
|
+
gate: reviewPhase.gate,
|
|
140
|
+
kind: 'fix',
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
function visitsFor(item, phaseId) {
|
|
144
|
+
return item.visits.get(phaseId) ?? 0;
|
|
145
|
+
}
|
|
146
|
+
function recordVisit(item, phaseId) {
|
|
147
|
+
item.visits.set(phaseId, visitsFor(item, phaseId) + 1);
|
|
148
|
+
}
|
|
149
|
+
function routeItem(workstream, item, toPhase, fromPhaseId) {
|
|
150
|
+
if (toPhase) {
|
|
151
|
+
item.currentPhaseId = toPhase.id;
|
|
152
|
+
item.state = 'awaiting-capacity';
|
|
153
|
+
emit({ type: 'item-advanced', workstreamId: workstream.id, itemId: item.id, fromPhaseId, toPhaseId: toPhase.id });
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
item.currentPhaseId = null;
|
|
157
|
+
item.state = 'passed';
|
|
158
|
+
item.completedAt = now();
|
|
159
|
+
emit({ type: 'item-passed', workstreamId: workstream.id, itemId: item.id });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function failItem(workstream, item, reason) {
|
|
163
|
+
item.state = 'failed';
|
|
164
|
+
item.completedAt = now();
|
|
165
|
+
item.failureReason = reason;
|
|
166
|
+
emit({ type: 'item-failed', workstreamId: workstream.id, itemId: item.id, reason });
|
|
167
|
+
}
|
|
168
|
+
/** Indirection to defeat TS's cross-await narrowing of item.state — see the call site below. */
|
|
169
|
+
function currentState(item) {
|
|
170
|
+
return item.state;
|
|
171
|
+
}
|
|
172
|
+
async function runItemPhase(workstream, item, phase) {
|
|
173
|
+
recordVisit(item, phase.id);
|
|
174
|
+
item.state = 'in-phase';
|
|
175
|
+
item.currentPhaseId = phase.id;
|
|
176
|
+
item.blockedReason = undefined;
|
|
177
|
+
const priorReports = getPhaseResults(workstream.id).filter((r) => r.itemId === item.id);
|
|
178
|
+
const outcome = await runPhase(workstream, item, phase, priorReports, {
|
|
179
|
+
agentManager: deps.agentManager,
|
|
180
|
+
configManager: deps.configManager,
|
|
181
|
+
runtimeBus: deps.runtimeBus,
|
|
182
|
+
projectRoot: deps.projectRoot,
|
|
183
|
+
sessionId,
|
|
184
|
+
createWorktree: deps.createWorktree,
|
|
185
|
+
cancellation,
|
|
186
|
+
priceUsage: deps.priceUsage,
|
|
187
|
+
skipClaimVerification: deps.skipClaimVerification,
|
|
188
|
+
launchDirtySnapshot,
|
|
189
|
+
});
|
|
190
|
+
item.usage = mergeUsageFromResult(item.usage, outcome.result.usage);
|
|
191
|
+
const results = completedResults.get(workstream.id) ?? [];
|
|
192
|
+
results.push(outcome.result);
|
|
193
|
+
completedResults.set(workstream.id, results);
|
|
194
|
+
emit({ type: 'workstream-persisted', workstreamId: workstream.id });
|
|
195
|
+
// kill() already transitioned the item to 'failed' synchronously before
|
|
196
|
+
// this promise settled — don't clobber that terminal state. Read through
|
|
197
|
+
// currentState() (not a direct `item.state` narrowing site) since TS's
|
|
198
|
+
// control-flow analysis otherwise "remembers" the 'in-phase' assignment
|
|
199
|
+
// above as if it still held after the `await`, even though kill() can
|
|
200
|
+
// mutate the same object concurrently while this call was in flight.
|
|
201
|
+
if (currentState(item) === 'failed')
|
|
202
|
+
return;
|
|
203
|
+
if (outcome.agentStatus === 'cancelled') {
|
|
204
|
+
failItem(workstream, item, 'cancelled by operator');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (outcome.agentStatus === 'failed') {
|
|
208
|
+
failItem(workstream, item, outcome.result.report.summary);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (outcome.result.gate.passed) {
|
|
212
|
+
// A fix phase's PASSING gate routes BACK to the review phase it was
|
|
213
|
+
// inserted after (dynamic insertion places 'fix' at an ordinal AFTER
|
|
214
|
+
// its review, per design (b) — "inserts a fix phase after review and
|
|
215
|
+
// re-routes that item back"), not forward past it. Every other kind
|
|
216
|
+
// advances to the next phase by ordinal as usual.
|
|
217
|
+
const forwardTarget = phase.kind === 'fix'
|
|
218
|
+
? (reviewPhaseBefore(workstream, phase) ?? nextPhaseAfter(workstream, phase.ordinal) ?? null)
|
|
219
|
+
: (nextPhaseAfter(workstream, phase.ordinal) ?? null);
|
|
220
|
+
routeItem(workstream, item, forwardTarget, phase.id);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// A review's FAILING gate with unsatisfied constraints gets one more
|
|
224
|
+
// cycle through a (found-or-inserted) fix phase, bounded by visits. A
|
|
225
|
+
// fix phase's own FAILING gate (the fixer didn't actually fix anything —
|
|
226
|
+
// phantom guard or quality gates caught it) is a genuine terminal
|
|
227
|
+
// failure, not something to retry-loop.
|
|
228
|
+
const unsatisfied = outcome.result.gate.unsatisfiedConstraintIds ?? [];
|
|
229
|
+
if (phase.kind === 'review' && unsatisfied.length > 0 && visitsFor(item, phase.id) < maxPhaseVisits) {
|
|
230
|
+
const fixPhase = findOrInsertFixPhase(workstream, phase);
|
|
231
|
+
routeItem(workstream, item, fixPhase, phase.id);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
failItem(workstream, item, outcome.result.gate.results.filter((r) => !r.passed).map((r) => `${r.gate}: ${r.output}`).join('; ') || 'gate failed');
|
|
235
|
+
}
|
|
236
|
+
function mergeUsageFromResult(current, added) {
|
|
237
|
+
const sawReasoning = current.reasoningTokens !== undefined || added.reasoningTokens !== undefined;
|
|
238
|
+
const bothPriced = current.costState === 'priced' && added.costState === 'priced';
|
|
239
|
+
const neitherPriced = current.costUsd === null && added.costUsd === null;
|
|
240
|
+
return {
|
|
241
|
+
inputTokens: current.inputTokens + added.inputTokens,
|
|
242
|
+
outputTokens: current.outputTokens + added.outputTokens,
|
|
243
|
+
cacheReadTokens: current.cacheReadTokens + added.cacheReadTokens,
|
|
244
|
+
cacheWriteTokens: current.cacheWriteTokens + added.cacheWriteTokens,
|
|
245
|
+
reasoningTokens: sawReasoning ? (current.reasoningTokens ?? 0) + (added.reasoningTokens ?? 0) : undefined,
|
|
246
|
+
llmCallCount: current.llmCallCount + added.llmCallCount,
|
|
247
|
+
turnCount: current.turnCount + added.turnCount,
|
|
248
|
+
toolCallCount: current.toolCallCount + added.toolCallCount,
|
|
249
|
+
costUsd: current.costUsd !== null && added.costUsd !== null ? current.costUsd + added.costUsd : (current.costUsd ?? added.costUsd),
|
|
250
|
+
costState: bothPriced ? 'priced' : neitherPriced ? 'unpriced' : 'estimated',
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function tick(workstreamId) {
|
|
254
|
+
if (disposed)
|
|
255
|
+
return;
|
|
256
|
+
const workstream = workstreams.get(workstreamId);
|
|
257
|
+
if (!workstream)
|
|
258
|
+
return;
|
|
259
|
+
const claims = computeClaims(workstream);
|
|
260
|
+
for (const { item, phase } of claims) {
|
|
261
|
+
const budgetCheck = checkBudget(workstream);
|
|
262
|
+
if (!budgetCheck.allowed) {
|
|
263
|
+
// Re-checked on every tick (computeClaims keeps 'blocked-budget'
|
|
264
|
+
// items in the waiting set — see scheduler.ts) rather than a
|
|
265
|
+
// one-time transition: the reason can change between checks (e.g.
|
|
266
|
+
// token ceiling was the blocker, then a cost ceiling is reached
|
|
267
|
+
// too), so keep it current even though the event only fires once
|
|
268
|
+
// per NEW block to avoid spamming listeners on every idle tick.
|
|
269
|
+
const wasAlreadyBlocked = item.state === 'blocked-budget';
|
|
270
|
+
item.state = 'blocked-budget';
|
|
271
|
+
item.blockedReason = budgetCheck.reason;
|
|
272
|
+
if (!wasAlreadyBlocked) {
|
|
273
|
+
emit({ type: 'item-blocked-budget', workstreamId, itemId: item.id, phaseId: phase.id, reason: budgetCheck.reason ?? 'budget ceiling reached' });
|
|
274
|
+
}
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
void runItemPhase(workstream, item, phase)
|
|
278
|
+
.catch((error) => {
|
|
279
|
+
logger.error('orchestration engine: phase run threw', {
|
|
280
|
+
workstreamId, itemId: item.id, phaseId: phase.id, error: summarizeError(error),
|
|
281
|
+
});
|
|
282
|
+
failItem(workstream, item, summarizeError(error));
|
|
283
|
+
})
|
|
284
|
+
.finally(() => {
|
|
285
|
+
if (!disposed)
|
|
286
|
+
tick(workstreamId);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
function start(workstreamId) {
|
|
291
|
+
tick(workstreamId);
|
|
292
|
+
}
|
|
293
|
+
function findItemAndWorkstream(itemId) {
|
|
294
|
+
for (const workstream of workstreams.values()) {
|
|
295
|
+
const item = workstream.items.find((i) => i.id === itemId);
|
|
296
|
+
if (item)
|
|
297
|
+
return { workstream, item };
|
|
298
|
+
}
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
function kill(itemId) {
|
|
302
|
+
const found = findItemAndWorkstream(itemId);
|
|
303
|
+
if (!found)
|
|
304
|
+
return false;
|
|
305
|
+
const { workstream, item } = found;
|
|
306
|
+
if (item.state === 'passed' || item.state === 'failed')
|
|
307
|
+
return false;
|
|
308
|
+
cancellation.abort(itemId);
|
|
309
|
+
if (item.agentId)
|
|
310
|
+
deps.agentManager.cancel(item.agentId, 'kill');
|
|
311
|
+
failItem(workstream, item, 'cancelled by operator');
|
|
312
|
+
emit({ type: 'item-cancelled', workstreamId: workstream.id, itemId, reason: 'cancelled by operator' });
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
function updateBudget(workstreamId, ceiling) {
|
|
316
|
+
const workstream = workstreams.get(workstreamId);
|
|
317
|
+
if (!workstream)
|
|
318
|
+
return false;
|
|
319
|
+
workstream.budget = ceiling;
|
|
320
|
+
tick(workstreamId);
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
function serializeWorkstream(workstreamId) {
|
|
324
|
+
const workstream = workstreams.get(workstreamId);
|
|
325
|
+
if (!workstream)
|
|
326
|
+
return null;
|
|
327
|
+
return serializeWorkstreamSnapshot(workstream, getPhaseResults(workstreamId));
|
|
328
|
+
}
|
|
329
|
+
function isTerminalWorkstream(workstream) {
|
|
330
|
+
return workstream.items.every((item) => item.state === 'passed' || item.state === 'failed');
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* An item persisted as 'in-phase' was mid-run when the snapshot was
|
|
334
|
+
* written (the debounced writer, persistence.ts, captures state
|
|
335
|
+
* synchronously — see runItemPhase — so this is always a crash artifact,
|
|
336
|
+
* never a live agent this process could still be waiting on). Left
|
|
337
|
+
* verbatim, it would count as an OCCUPIED capacity slot forever
|
|
338
|
+
* (computeClaims, scheduler.ts) while never being in the re-claimable
|
|
339
|
+
* waiting set, permanently starving every sibling in the same phase.
|
|
340
|
+
* Requeue it as 'pending' and drop the stale agentId so the next tick()
|
|
341
|
+
* reclaims it like any other waiting item — its prior PhaseResult (if any)
|
|
342
|
+
* was only ever pushed AFTER phase completion (see runItemPhase), so
|
|
343
|
+
* re-running the phase from here cannot produce a duplicate result.
|
|
344
|
+
*/
|
|
345
|
+
function reconcileImportedItems(workstream) {
|
|
346
|
+
for (const item of workstream.items) {
|
|
347
|
+
if (item.state !== 'in-phase')
|
|
348
|
+
continue;
|
|
349
|
+
item.state = 'pending';
|
|
350
|
+
item.agentId = undefined;
|
|
351
|
+
emit({
|
|
352
|
+
type: 'item-requeued',
|
|
353
|
+
workstreamId: workstream.id,
|
|
354
|
+
itemId: item.id,
|
|
355
|
+
reason: 're-queued after restart — was in-phase when the snapshot was written',
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
function importWorkstream(snapshotJson, force = false) {
|
|
360
|
+
const snapshot = deserializeWorkstreamSnapshot(snapshotJson);
|
|
361
|
+
if (!snapshot)
|
|
362
|
+
return false;
|
|
363
|
+
const workstream = deserializeWorkstreamModel(snapshot.workstream);
|
|
364
|
+
const existing = workstreams.get(workstream.id);
|
|
365
|
+
if (existing && !isTerminalWorkstream(existing) && !force) {
|
|
366
|
+
logger.warn('orchestration engine: importWorkstream refused — existing workstream is non-terminal; use force=true to overwrite', {
|
|
367
|
+
workstreamId: workstream.id,
|
|
368
|
+
});
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
reconcileImportedItems(workstream);
|
|
372
|
+
workstreams.set(workstream.id, workstream);
|
|
373
|
+
completedResults.set(workstream.id, [...snapshot.completedResults]);
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
function resumeWorkstream(workstreamId) {
|
|
377
|
+
if (!workstreams.has(workstreamId)) {
|
|
378
|
+
const snapshot = loadWorkstreamSnapshot(deps.projectRoot, workstreamId);
|
|
379
|
+
if (!snapshot)
|
|
380
|
+
return false;
|
|
381
|
+
if (!importWorkstream(JSON.stringify(snapshot)))
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
tick(workstreamId);
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
function resumeAllFromDisk() {
|
|
388
|
+
let count = 0;
|
|
389
|
+
for (const workstreamId of listSnapshotWorkstreamIds(deps.projectRoot)) {
|
|
390
|
+
if (resumeWorkstream(workstreamId))
|
|
391
|
+
count += 1;
|
|
392
|
+
}
|
|
393
|
+
return count;
|
|
394
|
+
}
|
|
395
|
+
function dispose() {
|
|
396
|
+
if (disposed)
|
|
397
|
+
return;
|
|
398
|
+
disposed = true;
|
|
399
|
+
unsubscribeWriter();
|
|
400
|
+
listeners.clear();
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
createWorkstream,
|
|
404
|
+
getWorkstream,
|
|
405
|
+
listWorkstreams,
|
|
406
|
+
insertPhase,
|
|
407
|
+
start,
|
|
408
|
+
kill,
|
|
409
|
+
updateBudget,
|
|
410
|
+
getPhaseResults,
|
|
411
|
+
serializeWorkstream,
|
|
412
|
+
importWorkstream,
|
|
413
|
+
resumeWorkstream,
|
|
414
|
+
resumeAllFromDisk,
|
|
415
|
+
on,
|
|
416
|
+
dispose,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** SDK-owned platform module. This implementation is maintained in goodvibes-sdk. */
|
|
2
|
+
export type { BudgetCeiling, CommitExclusion, GateOutcome, OrchestrationEvent, OrchestrationEventListener, Phase, PhaseGateSpec, PhaseKind, PhaseResult, PhaseRole, PhaseSpec, SerializedWorkItem, SerializedWorkstream, WorkItem, WorkItemSpec, WorkItemState, WorkItemUsage, Workstream, WorkstreamSnapshot, } from './types.js';
|
|
3
|
+
export { CURRENT_WORKSTREAM_SCHEMA_VERSION, emptyWorkItemUsage } from './types.js';
|
|
4
|
+
export type { CreateWorkstreamInput, OrchestrationEngine, OrchestrationEngineDeps } from './engine.js';
|
|
5
|
+
export { createOrchestrationEngine } from './engine.js';
|
|
6
|
+
export type { CancellationRegistry } from './cancellation.js';
|
|
7
|
+
export { createCancellationRegistry } from './cancellation.js';
|
|
8
|
+
export type { BudgetCheck } from './budget.js';
|
|
9
|
+
export { checkBudget } from './budget.js';
|
|
10
|
+
export { deserializeWorkstream, deserializeWorkstreamSnapshot, listSnapshotWorkstreamIds, loadWorkstreamSnapshot, serializeWorkstream, serializeWorkstreamSnapshot, writeWorkstreamSnapshot, } from './persistence.js';
|
|
11
|
+
export { fromChainSpec } from './controller-compat.js';
|
|
12
|
+
export type { PhaseRunnerAgentManagerLike, PhaseRunnerDeps, PhaseRunOutcome, WrfcWorktreeOps } from './phase-runner.js';
|
|
13
|
+
export { runPhase } from './phase-runner.js';
|
|
14
|
+
export type { DirtyLaunchSnapshot, ScopedCommitExclusion } from './dirty-guard.js';
|
|
15
|
+
export { excludeUntouchedLaunchResidue, hashWorkingTreeFile, snapshotDirtyTree } from './dirty-guard.js';
|
|
16
|
+
export { computeClaims, firstPhase, nextPhaseAfter, phaseById, reviewPhaseBefore, sortedPhases, } from './scheduler.js';
|
|
17
|
+
export type { PhaseClaim } from './scheduler.js';
|
|
18
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/platform/orchestration/index.ts"],"names":[],"mappings":"AAAA,qFAAqF;AAMrF,YAAY,EACV,aAAa,EACb,eAAe,EACf,WAAW,EACX,kBAAkB,EAClB,0BAA0B,EAC1B,KAAK,EACL,aAAa,EACb,SAAS,EACT,WAAW,EACX,SAAS,EACT,SAAS,EACT,kBAAkB,EAClB,oBAAoB,EACpB,QAAQ,EACR,YAAY,EACZ,aAAa,EACb,aAAa,EACb,UAAU,EACV,kBAAkB,GACnB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,iCAAiC,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAEnF,YAAY,EAAE,qBAAqB,EAAE,mBAAmB,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAC;AACvG,OAAO,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAC;AAExD,YAAY,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAC9D,OAAO,EAAE,0BAA0B,EAAE,MAAM,mBAAmB,CAAC;AAE/D,YAAY,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE1C,OAAO,EACL,qBAAqB,EACrB,6BAA6B,EAC7B,yBAAyB,EACzB,sBAAsB,EACtB,mBAAmB,EACnB,2BAA2B,EAC3B,uBAAuB,GACxB,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAEvD,YAAY,EAAE,2BAA2B,EAAE,eAAe,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACxH,OAAO,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAE7C,YAAY,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAC;AACnF,OAAO,EAAE,6BAA6B,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAEzG,OAAO,EACL,aAAa,EACb,UAAU,EACV,cAAc,EACd,SAAS,EACT,iBAAiB,EACjB,YAAY,GACb,MAAM,gBAAgB,CAAC;AACxB,YAAY,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** SDK-owned platform module. This implementation is maintained in goodvibes-sdk. */
|
|
2
|
+
export { CURRENT_WORKSTREAM_SCHEMA_VERSION, emptyWorkItemUsage } from './types.js';
|
|
3
|
+
export { createOrchestrationEngine } from './engine.js';
|
|
4
|
+
export { createCancellationRegistry } from './cancellation.js';
|
|
5
|
+
export { checkBudget } from './budget.js';
|
|
6
|
+
export { deserializeWorkstream, deserializeWorkstreamSnapshot, listSnapshotWorkstreamIds, loadWorkstreamSnapshot, serializeWorkstream, serializeWorkstreamSnapshot, writeWorkstreamSnapshot, } from './persistence.js';
|
|
7
|
+
export { fromChainSpec } from './controller-compat.js';
|
|
8
|
+
export { runPhase } from './phase-runner.js';
|
|
9
|
+
export { excludeUntouchedLaunchResidue, hashWorkingTreeFile, snapshotDirtyTree } from './dirty-guard.js';
|
|
10
|
+
export { computeClaims, firstPhase, nextPhaseAfter, phaseById, reviewPhaseBefore, sortedPhases, } from './scheduler.js';
|