@synergenius/flow-weaver-pack-weaver 0.9.151 → 0.9.153
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/ai-chat-provider.js +4 -4
- package/dist/ai-chat-provider.js.map +1 -1
- package/dist/bot/ai-client.d.ts +30 -0
- package/dist/bot/ai-client.d.ts.map +1 -1
- package/dist/bot/ai-client.js +37 -0
- package/dist/bot/ai-client.js.map +1 -1
- package/dist/bot/behavior-defaults.d.ts.map +1 -1
- package/dist/bot/behavior-defaults.js +7 -2
- package/dist/bot/behavior-defaults.js.map +1 -1
- package/dist/bot/capability-registry.d.ts.map +1 -1
- package/dist/bot/capability-registry.js +48 -30
- package/dist/bot/capability-registry.js.map +1 -1
- package/dist/bot/file-validator.d.ts +7 -0
- package/dist/bot/file-validator.d.ts.map +1 -1
- package/dist/bot/file-validator.js +76 -0
- package/dist/bot/file-validator.js.map +1 -1
- package/dist/bot/instance-manager.d.ts +22 -7
- package/dist/bot/instance-manager.d.ts.map +1 -1
- package/dist/bot/instance-manager.js +69 -7
- package/dist/bot/instance-manager.js.map +1 -1
- package/dist/bot/orchestrator.d.ts +11 -9
- package/dist/bot/orchestrator.d.ts.map +1 -1
- package/dist/bot/orchestrator.js +56 -107
- package/dist/bot/orchestrator.js.map +1 -1
- package/dist/bot/runner.d.ts +29 -0
- package/dist/bot/runner.d.ts.map +1 -1
- package/dist/bot/runner.js +114 -73
- package/dist/bot/runner.js.map +1 -1
- package/dist/bot/step-executor.d.ts.map +1 -1
- package/dist/bot/step-executor.js +106 -25
- package/dist/bot/step-executor.js.map +1 -1
- package/dist/bot/swarm-controller.d.ts +7 -6
- package/dist/bot/swarm-controller.d.ts.map +1 -1
- package/dist/bot/swarm-controller.js +64 -74
- package/dist/bot/swarm-controller.js.map +1 -1
- package/dist/bot/task-types.d.ts +1 -0
- package/dist/bot/task-types.d.ts.map +1 -1
- package/dist/bot/weaver-tools.d.ts +1 -1
- package/dist/bot/weaver-tools.d.ts.map +1 -1
- package/dist/bot/weaver-tools.js +6 -1
- package/dist/bot/weaver-tools.js.map +1 -1
- package/dist/node-types/agent-execute.js +2 -2
- package/dist/node-types/agent-execute.js.map +1 -1
- package/dist/node-types/bot-report.d.ts.map +1 -1
- package/dist/node-types/bot-report.js +5 -2
- package/dist/node-types/bot-report.js.map +1 -1
- package/dist/node-types/build-context.js +2 -1
- package/dist/node-types/build-context.js.map +1 -1
- package/dist/node-types/exec-validate-retry.d.ts +3 -3
- package/dist/node-types/exec-validate-retry.d.ts.map +1 -1
- package/dist/node-types/exec-validate-retry.js +13 -184
- package/dist/node-types/exec-validate-retry.js.map +1 -1
- package/dist/node-types/load-config.d.ts +1 -0
- package/dist/node-types/load-config.d.ts.map +1 -1
- package/dist/node-types/load-config.js +1 -0
- package/dist/node-types/load-config.js.map +1 -1
- package/dist/node-types/plan-task.d.ts +7 -5
- package/dist/node-types/plan-task.d.ts.map +1 -1
- package/dist/node-types/plan-task.js +282 -83
- package/dist/node-types/plan-task.js.map +1 -1
- package/dist/ui/bot-panel.js +1 -1
- package/dist/ui/capability-editor.js +48 -30
- package/dist/ui/profile-editor.js +46 -28
- package/dist/ui/swarm-dashboard.js +71 -33
- package/dist/ui/task-detail-view.js +22 -2
- package/dist/workflows/weaver-bot.d.ts +2 -2
- package/dist/workflows/weaver-bot.d.ts.map +1 -1
- package/dist/workflows/weaver-bot.js +5 -4
- package/dist/workflows/weaver-bot.js.map +1 -1
- package/flowweaver.manifest.json +1 -1
- package/package.json +1 -1
- package/src/ai-chat-provider.ts +4 -4
- package/src/bot/ai-client.ts +65 -0
- package/src/bot/behavior-defaults.ts +5 -2
- package/src/bot/capability-registry.ts +48 -30
- package/src/bot/file-validator.ts +97 -0
- package/src/bot/instance-manager.ts +77 -7
- package/src/bot/orchestrator.ts +63 -126
- package/src/bot/runner.ts +124 -70
- package/src/bot/step-executor.ts +115 -25
- package/src/bot/swarm-controller.ts +65 -76
- package/src/bot/task-types.ts +1 -0
- package/src/bot/weaver-tools.ts +7 -1
- package/src/node-types/agent-execute.ts +2 -2
- package/src/node-types/bot-report.ts +5 -2
- package/src/node-types/build-context.ts +2 -1
- package/src/node-types/exec-validate-retry.ts +14 -203
- package/src/node-types/load-config.ts +1 -0
- package/src/node-types/plan-task.ts +313 -88
- package/src/ui/bot-panel.tsx +1 -1
- package/src/ui/swarm-dashboard.tsx +3 -3
- package/src/ui/task-detail-view.tsx +25 -2
- package/src/workflows/weaver-bot.ts +5 -4
|
@@ -48,30 +48,28 @@ const CAP_ROLE_ORCHESTRATOR: CapabilityDefinition = {
|
|
|
48
48
|
You DECOMPOSE and ASSIGN. You never write code or create files directly.
|
|
49
49
|
|
|
50
50
|
Your job:
|
|
51
|
-
1. Analyze the objective
|
|
52
|
-
2.
|
|
53
|
-
3.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
### Project Brief Format
|
|
66
|
-
Include this at the TOP of every subtask description:
|
|
67
|
-
"PROJECT: [what we're building]. STRUCTURE: [file layout]. CONVENTIONS: [naming, patterns, exports]."
|
|
51
|
+
1. Analyze the objective
|
|
52
|
+
2. Break it into focused subtasks via task_create. Set parentId to "@self" on every subtask.
|
|
53
|
+
3. ALWAYS set assignedProfile: "developer", "reviewer", or "ops".
|
|
54
|
+
NEVER set assignedProfile to "orchestrator" — assigning to yourself creates an infinite loop.
|
|
55
|
+
4. Use the EXACT title of a previous subtask as dependsOn. The system resolves titles to real task IDs.
|
|
56
|
+
5. Include a project brief in every subtask: "PROJECT: [what]. FILES: [exact paths from workspace root]. CONVENTIONS: [patterns]."
|
|
57
|
+
|
|
58
|
+
CRITICAL: You do NOT have write_file, patch_file, or run_shell. Only plan and delegate.
|
|
59
|
+
|
|
60
|
+
### Design Phase (MANDATORY)
|
|
61
|
+
Your FIRST subtask MUST be a design task assigned to ops that creates a .design.md file in the project root. This is the single source of truth. It must contain:
|
|
62
|
+
- Module map, TypeScript interfaces (copy-paste ready), export contracts (function signatures)
|
|
63
|
+
- Dependency graph, conventions (naming, error handling, patterns)
|
|
64
|
+
Every subsequent developer task MUST read .design.md before writing code.
|
|
68
65
|
|
|
69
66
|
### Subtask Quality
|
|
70
|
-
Each subtask
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
67
|
+
Each subtask: focused (one concern), self-contained, properly routed, ordered by dependsOn.
|
|
68
|
+
|
|
69
|
+
### Example
|
|
70
|
+
{ operation: "task_create", args: { title: "Design: Create project contract", parentId: "@self", assignedProfile: "ops", complexity: "complex", description: "Create todo-app/.design.md with module map, TypeScript interfaces, export contracts.", dependsOn: [] } }
|
|
71
|
+
{ operation: "task_create", args: { title: "Setup project", parentId: "@self", assignedProfile: "ops", dependsOn: ["Design: Create project contract"] } }
|
|
72
|
+
{ operation: "task_create", args: { title: "Write code", parentId: "@self", assignedProfile: "developer", dependsOn: ["Setup project"] } }`,
|
|
75
73
|
};
|
|
76
74
|
|
|
77
75
|
const CAP_ROLE_DEVELOPER: CapabilityDefinition = {
|
|
@@ -81,17 +79,28 @@ const CAP_ROLE_DEVELOPER: CapabilityDefinition = {
|
|
|
81
79
|
You WRITE CODE. Execute the task directly using write_file, patch_file, and run_shell.
|
|
82
80
|
|
|
83
81
|
Your job:
|
|
84
|
-
1. Read the
|
|
85
|
-
2.
|
|
86
|
-
3.
|
|
87
|
-
4. Verify your
|
|
82
|
+
1. Read .design.md in the project root to understand interfaces and contracts
|
|
83
|
+
2. Read files created by previous tasks (your dependencies are done — their files are on disk)
|
|
84
|
+
3. Write code that MATCHES the contracts in .design.md exactly — same types, same function signatures, same exports
|
|
85
|
+
4. Verify your imports resolve to real exports in existing files
|
|
88
86
|
|
|
89
87
|
You do NOT have task_create. You cannot create subtasks or delegate.
|
|
90
88
|
If the task seems too large, do your best — the orchestrator already decomposed it for you.
|
|
91
89
|
|
|
90
|
+
### File Paths
|
|
91
|
+
All paths in write_file/patch_file are RELATIVE TO THE WORKSPACE ROOT. If the task says "inside todo-app/", your paths MUST start with todo-app/ (e.g., todo-app/src/cli.ts, NOT src/cli.ts).
|
|
92
|
+
|
|
93
|
+
### Code Quality
|
|
94
|
+
- Write COMPLETE, WORKING code. No TODOs, no placeholders, no empty function bodies, no "// implement later".
|
|
95
|
+
- Every function must be fully implemented with real logic.
|
|
96
|
+
- Use proper TypeScript types. Use strict mode patterns.
|
|
97
|
+
- Export everything that other files will import.
|
|
98
|
+
- Handle edge cases (empty input, file not found, invalid args).
|
|
99
|
+
- Use ESM-compatible patterns: import.meta.url instead of __dirname, import.meta.filename instead of __filename. Use fileURLToPath(import.meta.url) for path resolution.
|
|
100
|
+
|
|
92
101
|
### Output Requirements
|
|
93
102
|
Your plan MUST include at least one write_file, patch_file, or run_shell step.
|
|
94
|
-
A plan with only
|
|
103
|
+
A plan with only read_file, list_files, or respond steps is a FAILURE — you must produce artifacts.`,
|
|
95
104
|
};
|
|
96
105
|
|
|
97
106
|
const CAP_ROLE_REVIEWER: CapabilityDefinition = {
|
|
@@ -117,10 +126,19 @@ const CAP_ROLE_OPS: CapabilityDefinition = {
|
|
|
117
126
|
You SET UP infrastructure — package.json, tsconfig.json, directory structure, dependencies.
|
|
118
127
|
|
|
119
128
|
Your job:
|
|
120
|
-
1.
|
|
121
|
-
2.
|
|
122
|
-
3.
|
|
129
|
+
1. Create the project directory first: run_shell with mkdir -p <project>/src
|
|
130
|
+
2. Write config files (package.json, tsconfig.json) using write_file
|
|
131
|
+
3. Install dependencies with run_shell (npm install)
|
|
132
|
+
4. Ensure the project structure is ready for developers
|
|
133
|
+
|
|
134
|
+
### File Paths
|
|
135
|
+
All paths are RELATIVE TO THE WORKSPACE ROOT. If the project is in a subfolder (e.g., todo-app/), ALL your paths must include that prefix: todo-app/package.json, todo-app/tsconfig.json, todo-app/src/.
|
|
123
136
|
|
|
137
|
+
### Design Tasks
|
|
138
|
+
When the task is a Design task, create a .design.md file with detailed TypeScript interfaces, module exports, and dependency graph. This file must contain copy-paste ready interface definitions that developers will implement exactly.
|
|
139
|
+
|
|
140
|
+
### Output Requirements
|
|
141
|
+
Your plan MUST include write_file and/or run_shell steps that create real files.
|
|
124
142
|
You do NOT have task_create. You execute infrastructure tasks directly.`,
|
|
125
143
|
};
|
|
126
144
|
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
1
4
|
import { checkDesignQuality, type DesignReport } from './design-checker.js';
|
|
2
5
|
import { fwValidate } from './fw-api.js';
|
|
3
6
|
|
|
@@ -17,6 +20,18 @@ export async function validateFiles(
|
|
|
17
20
|
|
|
18
21
|
for (const file of files) {
|
|
19
22
|
if (!file.endsWith('.ts')) continue;
|
|
23
|
+
|
|
24
|
+
// Only run FW validation on files that contain @flowWeaver annotations.
|
|
25
|
+
// Plain TypeScript files (like server.ts) should not be FW-validated.
|
|
26
|
+
const absFile = path.isAbsolute(file) ? file : path.resolve(projectDir, file);
|
|
27
|
+
let hasFwAnnotation = false;
|
|
28
|
+
try {
|
|
29
|
+
const content = fs.readFileSync(absFile, 'utf-8');
|
|
30
|
+
hasFwAnnotation = content.includes('@flowWeaver') || content.includes('@flow-weaver');
|
|
31
|
+
} catch { /* file read failed — skip FW validation */ }
|
|
32
|
+
|
|
33
|
+
if (!hasFwAnnotation) continue;
|
|
34
|
+
|
|
20
35
|
try {
|
|
21
36
|
const { valid, errors, warnings, ast } = await fwValidate(file);
|
|
22
37
|
|
|
@@ -39,3 +54,85 @@ export async function validateFiles(
|
|
|
39
54
|
|
|
40
55
|
return results;
|
|
41
56
|
}
|
|
57
|
+
|
|
58
|
+
export interface TscValidationResult {
|
|
59
|
+
valid: boolean;
|
|
60
|
+
errors: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Find the tsc binary: prefer the project's own node_modules/.bin/tsc,
|
|
65
|
+
* then walk up parent directories, then fall back to a bare 'tsc' (PATH lookup).
|
|
66
|
+
*/
|
|
67
|
+
function findTscBin(projectDir: string): string | null {
|
|
68
|
+
let dir = projectDir;
|
|
69
|
+
// eslint-disable-next-line no-constant-condition
|
|
70
|
+
while (true) {
|
|
71
|
+
const candidate = path.join(dir, 'node_modules', '.bin', 'tsc');
|
|
72
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
73
|
+
const parent = path.dirname(dir);
|
|
74
|
+
if (parent === dir) break;
|
|
75
|
+
dir = parent;
|
|
76
|
+
}
|
|
77
|
+
return null; // caller will try bare 'tsc'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Strip ANSI escape codes from tsc output
|
|
81
|
+
function stripAnsi(str: string): string {
|
|
82
|
+
// eslint-disable-next-line no-control-regex
|
|
83
|
+
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function validateTypeScript(
|
|
87
|
+
projectDir: string,
|
|
88
|
+
opts?: { timeoutMs?: number },
|
|
89
|
+
): Promise<TscValidationResult> {
|
|
90
|
+
const timeoutMs = opts?.timeoutMs ?? 30_000;
|
|
91
|
+
|
|
92
|
+
// Check for tsconfig.json
|
|
93
|
+
const tsconfigPath = path.join(projectDir, 'tsconfig.json');
|
|
94
|
+
if (!fs.existsSync(tsconfigPath)) {
|
|
95
|
+
return { valid: true, errors: [] };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const tscBin = findTscBin(projectDir) ?? 'tsc';
|
|
99
|
+
|
|
100
|
+
return new Promise<TscValidationResult>((resolve) => {
|
|
101
|
+
try {
|
|
102
|
+
const child = execFile(
|
|
103
|
+
tscBin,
|
|
104
|
+
['--noEmit', '--pretty', 'false'],
|
|
105
|
+
{ cwd: projectDir, timeout: timeoutMs, env: { ...process.env } },
|
|
106
|
+
(error, stdout, stderr) => {
|
|
107
|
+
if (!error) {
|
|
108
|
+
return resolve({ valid: true, errors: [] });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// If the process was killed (timeout) or spawn failed, graceful fallback
|
|
112
|
+
if (error.killed || (error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
113
|
+
return resolve({ valid: true, errors: [] });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// tsc exits with code 2 on type errors; parse stdout for error lines
|
|
117
|
+
const output = stripAnsi((stdout || '') + (stderr || ''));
|
|
118
|
+
const lines = output.split('\n').filter((l) => l.trim().length > 0);
|
|
119
|
+
if (lines.length === 0) {
|
|
120
|
+
return resolve({ valid: true, errors: [] });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Strip absolute projectDir prefix so errors use relative paths
|
|
124
|
+
const cleaned = lines.map(l => l.replaceAll(projectDir + '/', ''));
|
|
125
|
+
return resolve({ valid: false, errors: cleaned });
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// Extra safety: if the child can't even spawn
|
|
130
|
+
child.on('error', () => {
|
|
131
|
+
resolve({ valid: true, errors: [] });
|
|
132
|
+
});
|
|
133
|
+
} catch {
|
|
134
|
+
// execFile itself threw (e.g. bad args) — graceful fallback
|
|
135
|
+
resolve({ valid: true, errors: [] });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* InstanceManager — manages
|
|
3
|
-
*
|
|
2
|
+
* InstanceManager — manages a dynamic pool of generic worker slots.
|
|
3
|
+
*
|
|
4
|
+
* Workers are profile-agnostic: any worker can run any profile's task.
|
|
5
|
+
* The profile is loaded per-task at execution time, not per-worker.
|
|
6
|
+
*
|
|
7
|
+
* Worker naming: `worker-0`, `worker-1`, ... (no per-profile instances).
|
|
4
8
|
*/
|
|
5
9
|
|
|
6
10
|
import type { BotProfile, BotInstance } from './profile-types.js';
|
|
@@ -8,7 +12,61 @@ import type { BotProfile, BotInstance } from './profile-types.js';
|
|
|
8
12
|
export class InstanceManager {
|
|
9
13
|
private instances = new Map<string, BotInstance>();
|
|
10
14
|
|
|
11
|
-
|
|
15
|
+
// -----------------------------------------------------------------------
|
|
16
|
+
// Worker pool API (new)
|
|
17
|
+
// -----------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/** Spawn a single generic worker slot at the given index. */
|
|
20
|
+
spawnWorker(index: number): BotInstance {
|
|
21
|
+
const instanceId = `worker-${index}`;
|
|
22
|
+
if (this.instances.has(instanceId)) {
|
|
23
|
+
throw new Error(`Worker already exists: ${instanceId}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const instance: BotInstance = {
|
|
27
|
+
instanceId,
|
|
28
|
+
profileId: '', // No profile until a task is assigned
|
|
29
|
+
index,
|
|
30
|
+
status: 'idle',
|
|
31
|
+
tokensUsed: 0,
|
|
32
|
+
cost: 0,
|
|
33
|
+
tasksCompleted: 0,
|
|
34
|
+
tasksFailed: 0,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
this.instances.set(instanceId, instance);
|
|
38
|
+
return { ...instance };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Spawn a pool of N generic workers (worker-0 through worker-N-1). */
|
|
42
|
+
spawnPool(size: number): BotInstance[] {
|
|
43
|
+
const spawned: BotInstance[] = [];
|
|
44
|
+
for (let i = 0; i < size; i++) {
|
|
45
|
+
spawned.push(this.spawnWorker(i));
|
|
46
|
+
}
|
|
47
|
+
return spawned;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Find any idle worker (profile-agnostic). Returns a copy or null. */
|
|
51
|
+
findIdleWorker(): BotInstance | null {
|
|
52
|
+
for (const inst of this.instances.values()) {
|
|
53
|
+
if (inst.status === 'idle') return { ...inst };
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Return all idle workers. */
|
|
59
|
+
findIdleWorkers(): BotInstance[] {
|
|
60
|
+
return [...this.instances.values()]
|
|
61
|
+
.filter((i) => i.status === 'idle')
|
|
62
|
+
.map((i) => ({ ...i }));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// -----------------------------------------------------------------------
|
|
66
|
+
// Legacy per-profile API (kept for backward compat, used by tests)
|
|
67
|
+
// -----------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/** @deprecated Use spawnWorker/spawnPool instead. Spawn a profile-bound instance. */
|
|
12
70
|
spawn(profile: BotProfile): BotInstance {
|
|
13
71
|
const existing = this.listByProfile(profile.id);
|
|
14
72
|
if (existing.length >= profile.maxInstances) {
|
|
@@ -36,6 +94,10 @@ export class InstanceManager {
|
|
|
36
94
|
return { ...instance };
|
|
37
95
|
}
|
|
38
96
|
|
|
97
|
+
// -----------------------------------------------------------------------
|
|
98
|
+
// Shared API
|
|
99
|
+
// -----------------------------------------------------------------------
|
|
100
|
+
|
|
39
101
|
/** Get instance by ID (returns a copy), or null if not found. */
|
|
40
102
|
get(instanceId: string): BotInstance | null {
|
|
41
103
|
const inst = this.instances.get(instanceId);
|
|
@@ -61,17 +123,23 @@ export class InstanceManager {
|
|
|
61
123
|
.map((i) => ({ ...i }));
|
|
62
124
|
}
|
|
63
125
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Mark a worker as executing a specific task/run.
|
|
128
|
+
* @param profileId — the profile being loaded for this task execution.
|
|
129
|
+
*/
|
|
130
|
+
markExecuting(instanceId: string, taskId: string, runId: string, profileId?: string): void {
|
|
66
131
|
const inst = this.instances.get(instanceId);
|
|
67
132
|
if (!inst) throw new Error(`Instance not found: ${instanceId}`);
|
|
68
133
|
inst.status = 'executing';
|
|
69
134
|
inst.currentTaskId = taskId;
|
|
70
135
|
inst.currentRunId = runId;
|
|
71
136
|
inst.startedAt = new Date().toISOString();
|
|
137
|
+
if (profileId !== undefined) {
|
|
138
|
+
inst.profileId = profileId;
|
|
139
|
+
}
|
|
72
140
|
}
|
|
73
141
|
|
|
74
|
-
/** Mark
|
|
142
|
+
/** Mark a worker as idle after task completion/failure. Clears profile association. */
|
|
75
143
|
markIdle(instanceId: string, success: boolean): void {
|
|
76
144
|
const inst = this.instances.get(instanceId);
|
|
77
145
|
if (!inst) throw new Error(`Instance not found: ${instanceId}`);
|
|
@@ -79,6 +147,8 @@ export class InstanceManager {
|
|
|
79
147
|
inst.currentTaskId = undefined;
|
|
80
148
|
inst.currentRunId = undefined;
|
|
81
149
|
inst.startedAt = undefined;
|
|
150
|
+
// Clear profile — worker is generic again
|
|
151
|
+
inst.profileId = '';
|
|
82
152
|
if (success) {
|
|
83
153
|
inst.tasksCompleted++;
|
|
84
154
|
} else {
|
|
@@ -104,7 +174,7 @@ export class InstanceManager {
|
|
|
104
174
|
this.instances.clear();
|
|
105
175
|
}
|
|
106
176
|
|
|
107
|
-
/**
|
|
177
|
+
/** @deprecated No longer needed in pool model. Kept for backward compat. */
|
|
108
178
|
scaleTo(profile: BotProfile, target: number): void {
|
|
109
179
|
const clamped = Math.max(0, Math.min(target, profile.maxInstances));
|
|
110
180
|
const current = this.listByProfile(profile.id);
|
package/src/bot/orchestrator.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Orchestrator — the routing brain that decides which tasks go to which
|
|
2
|
+
* Orchestrator — the routing brain that decides which tasks go to which workers.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* In the worker pool model, workers are generic slots. The orchestrator:
|
|
5
|
+
* 1. Finds the profile for each task (via assignedProfile).
|
|
6
|
+
* 2. Assigns the task to any idle worker.
|
|
7
|
+
*
|
|
8
|
+
* Simplified cascade: exact-match profile → any idle worker.
|
|
9
|
+
* No more per-profile instance matching or scale-up actions.
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
import type {
|
|
@@ -15,7 +19,6 @@ import type {
|
|
|
15
19
|
|
|
16
20
|
type Task = OrchestratorInput['pendingTasks'][number];
|
|
17
21
|
type Assignment = OrchestratorOutput['assignments'][number];
|
|
18
|
-
type ScaleAction = OrchestratorOutput['scaleActions'][number];
|
|
19
22
|
|
|
20
23
|
// ---------------------------------------------------------------------------
|
|
21
24
|
// AI Router types
|
|
@@ -50,11 +53,10 @@ export class Orchestrator {
|
|
|
50
53
|
|
|
51
54
|
async route(input: OrchestratorInput): Promise<OrchestratorOutput> {
|
|
52
55
|
const assignments: Assignment[] = [];
|
|
53
|
-
const scaleActions: ScaleAction[] = [];
|
|
54
56
|
const skippedTasks: OrchestratorOutput['skippedTasks'] = [];
|
|
55
57
|
|
|
56
|
-
// Track
|
|
57
|
-
const
|
|
58
|
+
// Track workers claimed during this routing cycle to avoid double-assignment.
|
|
59
|
+
const claimedWorkerIds = new Set<string>();
|
|
58
60
|
|
|
59
61
|
// Sort tasks by priority DESC (higher number = higher priority).
|
|
60
62
|
const sorted = [...input.pendingTasks].sort((a, b) => b.priority - a.priority);
|
|
@@ -66,50 +68,43 @@ export class Orchestrator {
|
|
|
66
68
|
continue;
|
|
67
69
|
}
|
|
68
70
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (
|
|
71
|
+
// Resolve profile for this task (includes routing method)
|
|
72
|
+
const resolution = await this._resolveProfile(task, input.profiles);
|
|
73
|
+
if (!resolution) {
|
|
72
74
|
skippedTasks.push({ taskId: task.id, reason: 'no-eligible-profile' });
|
|
73
75
|
continue;
|
|
74
76
|
}
|
|
75
77
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
taskId: task.id,
|
|
82
|
-
profileId: result.profileId,
|
|
83
|
-
instanceId: result.instanceId,
|
|
84
|
-
reason: result.reason,
|
|
85
|
-
method: result.method,
|
|
86
|
-
confidence: result.confidence,
|
|
87
|
-
});
|
|
88
|
-
this._recordDecision(task, result, eligible);
|
|
89
|
-
} else {
|
|
90
|
-
// No idle instance available — request scale-up if possible.
|
|
91
|
-
const scaleProfile = eligible[0];
|
|
92
|
-
const currentCount = input.instances.filter(
|
|
93
|
-
(i) => i.profileId === scaleProfile.id,
|
|
94
|
-
).length;
|
|
95
|
-
|
|
96
|
-
if (currentCount < scaleProfile.maxInstances) {
|
|
97
|
-
// Only add one scale-up action per profile.
|
|
98
|
-
if (!scaleActions.some((sa) => sa.profileId === scaleProfile.id)) {
|
|
99
|
-
scaleActions.push({
|
|
100
|
-
profileId: scaleProfile.id,
|
|
101
|
-
action: 'scale-up',
|
|
102
|
-
targetInstances: currentCount + 1,
|
|
103
|
-
reason: `idle instances exhausted for profile ${scaleProfile.id}`,
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
skippedTasks.push({ taskId: task.id, reason: 'no-idle-instance' });
|
|
78
|
+
// Find any idle worker
|
|
79
|
+
const idleWorker = this._findIdleWorker(input.instances, claimedWorkerIds);
|
|
80
|
+
if (!idleWorker) {
|
|
81
|
+
skippedTasks.push({ taskId: task.id, reason: 'no-idle-worker' });
|
|
82
|
+
continue;
|
|
109
83
|
}
|
|
84
|
+
|
|
85
|
+
claimedWorkerIds.add(idleWorker.instanceId);
|
|
86
|
+
|
|
87
|
+
const reason = `${resolution.method}: ${resolution.profileId} → ${idleWorker.instanceId}`;
|
|
88
|
+
assignments.push({
|
|
89
|
+
taskId: task.id,
|
|
90
|
+
profileId: resolution.profileId,
|
|
91
|
+
instanceId: idleWorker.instanceId,
|
|
92
|
+
reason,
|
|
93
|
+
method: resolution.method,
|
|
94
|
+
confidence: resolution.confidence,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
this._recordDecision(task, {
|
|
98
|
+
profileId: resolution.profileId,
|
|
99
|
+
instanceId: idleWorker.instanceId,
|
|
100
|
+
method: resolution.method,
|
|
101
|
+
reason,
|
|
102
|
+
confidence: resolution.confidence,
|
|
103
|
+
}, input.profiles.filter(p => p.id === resolution.profileId));
|
|
110
104
|
}
|
|
111
105
|
|
|
112
|
-
|
|
106
|
+
// No scale actions in pool model — pool size is fixed at maxConcurrent
|
|
107
|
+
return { assignments, scaleActions: [], skippedTasks };
|
|
113
108
|
}
|
|
114
109
|
|
|
115
110
|
getDecisionLog(limit?: number): OrchestratorDecision[] {
|
|
@@ -126,106 +121,48 @@ export class Orchestrator {
|
|
|
126
121
|
// Internal
|
|
127
122
|
// ---------------------------------------------------------------------------
|
|
128
123
|
|
|
129
|
-
/**
|
|
130
|
-
private
|
|
124
|
+
/** Resolve which profileId should handle this task, including routing method. */
|
|
125
|
+
private async _resolveProfile(
|
|
126
|
+
task: Task,
|
|
127
|
+
profiles: BotProfile[],
|
|
128
|
+
): Promise<{ profileId: string; method: OrchestratorDecision['method']; confidence?: number } | null> {
|
|
129
|
+
// Exact match: task has assignedProfile
|
|
131
130
|
if (task.assignedProfile) {
|
|
132
|
-
const match = profiles.
|
|
133
|
-
if (match
|
|
134
|
-
// Assigned profile doesn't exist — return empty to skip this task
|
|
135
|
-
// rather than falling through to all profiles (silent mis-routing).
|
|
131
|
+
const match = profiles.find(p => p.id === task.assignedProfile);
|
|
132
|
+
if (match) return { profileId: match.id, method: 'exact-match' };
|
|
136
133
|
console.warn(
|
|
137
134
|
`[orchestrator] Task "${task.id}" assigned to profile "${task.assignedProfile}" which does not exist — skipping`,
|
|
138
135
|
);
|
|
139
|
-
return
|
|
136
|
+
return null;
|
|
140
137
|
}
|
|
141
|
-
return profiles;
|
|
142
|
-
}
|
|
143
138
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
eligible: BotProfile[],
|
|
148
|
-
instances: BotInstance[],
|
|
149
|
-
claimed: Set<string>,
|
|
150
|
-
): Promise<{ profileId: string; instanceId: string; method: OrchestratorDecision['method']; reason: string; confidence?: number } | null> {
|
|
151
|
-
// Determine routing method
|
|
152
|
-
let method = this._routingMethod(task, eligible);
|
|
153
|
-
|
|
154
|
-
if (eligible.length === 1) {
|
|
155
|
-
const profile = eligible[0];
|
|
156
|
-
const idle = instances.find(
|
|
157
|
-
(i) => i.profileId === profile.id && i.status === 'idle' && !claimed.has(i.instanceId),
|
|
158
|
-
);
|
|
159
|
-
if (!idle) return null;
|
|
160
|
-
return {
|
|
161
|
-
profileId: profile.id,
|
|
162
|
-
instanceId: idle.instanceId,
|
|
163
|
-
method,
|
|
164
|
-
reason: `${method}: ${profile.id}`,
|
|
165
|
-
};
|
|
139
|
+
// Single eligible profile
|
|
140
|
+
if (profiles.length === 1) {
|
|
141
|
+
return { profileId: profiles[0].id, method: 'single-eligible' };
|
|
166
142
|
}
|
|
167
143
|
|
|
168
|
-
//
|
|
169
|
-
if (this._aiRouter &&
|
|
144
|
+
// Multiple eligible profiles — try AI routing
|
|
145
|
+
if (this._aiRouter && profiles.length > 1) {
|
|
170
146
|
try {
|
|
171
|
-
const aiResult = await this._aiRouter.route(task,
|
|
172
|
-
const aiProfile =
|
|
147
|
+
const aiResult = await this._aiRouter.route(task, profiles);
|
|
148
|
+
const aiProfile = profiles.find(p => p.id === aiResult.profileId);
|
|
173
149
|
if (aiProfile) {
|
|
174
|
-
|
|
175
|
-
(i) => i.profileId === aiProfile.id && i.status === 'idle' && !claimed.has(i.instanceId),
|
|
176
|
-
);
|
|
177
|
-
if (idle) {
|
|
178
|
-
return {
|
|
179
|
-
profileId: aiProfile.id,
|
|
180
|
-
instanceId: idle.instanceId,
|
|
181
|
-
method: 'ai-routed',
|
|
182
|
-
reason: `ai-routed: ${aiProfile.id} — ${aiResult.reason}`,
|
|
183
|
-
confidence: aiResult.confidence,
|
|
184
|
-
};
|
|
185
|
-
}
|
|
186
|
-
// AI chose a profile but no idle instance — fall through to round-robin
|
|
150
|
+
return { profileId: aiProfile.id, method: 'ai-routed', confidence: aiResult.confidence };
|
|
187
151
|
}
|
|
188
|
-
// AI returned unknown profile — fall through to round-robin
|
|
189
152
|
} catch {
|
|
190
153
|
// AI call failed — fall through to round-robin
|
|
191
154
|
}
|
|
192
155
|
}
|
|
193
156
|
|
|
194
|
-
//
|
|
195
|
-
method
|
|
196
|
-
let bestProfile: BotProfile | null = null;
|
|
197
|
-
let bestIdleCount = -1;
|
|
198
|
-
let bestInstance: BotInstance | null = null;
|
|
199
|
-
|
|
200
|
-
for (const profile of eligible) {
|
|
201
|
-
const idleInstances = instances.filter(
|
|
202
|
-
(i) => i.profileId === profile.id && i.status === 'idle' && !claimed.has(i.instanceId),
|
|
203
|
-
);
|
|
204
|
-
if (idleInstances.length > bestIdleCount) {
|
|
205
|
-
bestIdleCount = idleInstances.length;
|
|
206
|
-
bestProfile = profile;
|
|
207
|
-
bestInstance = idleInstances[0] ?? null;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
if (!bestProfile || !bestInstance) return null;
|
|
212
|
-
|
|
213
|
-
return {
|
|
214
|
-
profileId: bestProfile.id,
|
|
215
|
-
instanceId: bestInstance.instanceId,
|
|
216
|
-
method,
|
|
217
|
-
reason: `${method}: ${bestProfile.id}`,
|
|
218
|
-
};
|
|
157
|
+
// Fallback: round-robin (first profile)
|
|
158
|
+
return profiles.length > 0 ? { profileId: profiles[0].id, method: 'round-robin' } : null;
|
|
219
159
|
}
|
|
220
160
|
|
|
221
|
-
/**
|
|
222
|
-
private
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (task.assignedProfile) return 'exact-match';
|
|
227
|
-
if (eligible.length === 1) return 'single-eligible';
|
|
228
|
-
return 'round-robin';
|
|
161
|
+
/** Find any idle worker that hasn't been claimed this cycle. */
|
|
162
|
+
private _findIdleWorker(instances: BotInstance[], claimed: Set<string>): BotInstance | null {
|
|
163
|
+
return instances.find(
|
|
164
|
+
(i) => i.status === 'idle' && !claimed.has(i.instanceId),
|
|
165
|
+
) ?? null;
|
|
229
166
|
}
|
|
230
167
|
|
|
231
168
|
/** Record a decision in the log. */
|