openclaw-node-harness 2.0.3 → 2.1.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/README.md +646 -3
- package/bin/hyperagent.mjs +419 -0
- package/bin/mesh-agent.js +603 -81
- package/bin/mesh-bridge.js +340 -11
- package/bin/mesh-deploy-listener.js +119 -97
- package/bin/mesh-deploy.js +8 -0
- package/bin/mesh-task-daemon.js +1005 -40
- package/bin/mesh.js +423 -6
- package/config/claude-settings.json +95 -0
- package/config/daemon.json.template +2 -1
- package/config/git-hooks/pre-commit +13 -0
- package/config/git-hooks/pre-push +12 -0
- package/config/harness-rules.json +174 -0
- package/config/plan-templates/team-bugfix.yaml +52 -0
- package/config/plan-templates/team-deploy.yaml +50 -0
- package/config/plan-templates/team-feature.yaml +71 -0
- package/config/roles/qa-engineer.yaml +36 -0
- package/config/roles/solidity-dev.yaml +51 -0
- package/config/roles/tech-architect.yaml +36 -0
- package/config/rules/framework/solidity.md +22 -0
- package/config/rules/framework/typescript.md +21 -0
- package/config/rules/framework/unity.md +21 -0
- package/config/rules/universal/design-docs.md +18 -0
- package/config/rules/universal/git-hygiene.md +18 -0
- package/config/rules/universal/security.md +19 -0
- package/config/rules/universal/test-standards.md +19 -0
- package/identity/DELEGATION.md +6 -6
- package/install.sh +300 -8
- package/lib/circling-parser.js +119 -0
- package/lib/hyperagent-store.mjs +652 -0
- package/lib/kanban-io.js +59 -10
- package/lib/mcp-knowledge/bench.mjs +118 -0
- package/lib/mcp-knowledge/core.mjs +528 -0
- package/lib/mcp-knowledge/package.json +25 -0
- package/lib/mcp-knowledge/server.mjs +245 -0
- package/lib/mcp-knowledge/test.mjs +802 -0
- package/lib/memory-budget.mjs +261 -0
- package/lib/mesh-collab.js +354 -4
- package/lib/mesh-harness.js +427 -0
- package/lib/mesh-plans.js +13 -5
- package/lib/mesh-registry.js +11 -2
- package/lib/mesh-tasks.js +67 -0
- package/lib/plan-templates.js +226 -0
- package/lib/pre-compression-flush.mjs +320 -0
- package/lib/role-loader.js +292 -0
- package/lib/rule-loader.js +358 -0
- package/lib/session-store.mjs +458 -0
- package/lib/transcript-parser.mjs +292 -0
- package/mission-control/drizzle/soul_schema_update.sql +29 -0
- package/mission-control/drizzle.config.ts +1 -4
- package/mission-control/package-lock.json +1571 -83
- package/mission-control/package.json +6 -2
- package/mission-control/scripts/gen-chronology.js +3 -3
- package/mission-control/scripts/import-pipeline-v2.js +0 -16
- package/mission-control/scripts/import-pipeline.js +0 -15
- package/mission-control/src/app/api/cowork/clusters/[id]/members/route.ts +117 -0
- package/mission-control/src/app/api/cowork/clusters/[id]/route.ts +84 -0
- package/mission-control/src/app/api/cowork/clusters/route.ts +141 -0
- package/mission-control/src/app/api/cowork/dispatch/route.ts +128 -0
- package/mission-control/src/app/api/cowork/events/route.ts +65 -0
- package/mission-control/src/app/api/cowork/intervene/route.ts +259 -0
- package/mission-control/src/app/api/cowork/sessions/[id]/route.ts +37 -0
- package/mission-control/src/app/api/cowork/sessions/route.ts +64 -0
- package/mission-control/src/app/api/diagnostics/route.ts +97 -0
- package/mission-control/src/app/api/diagnostics/test-runner/route.ts +990 -0
- package/mission-control/src/app/api/mesh/events/route.ts +95 -19
- package/mission-control/src/app/api/mesh/identity/route.ts +11 -0
- package/mission-control/src/app/api/mesh/tasks/[id]/route.ts +92 -0
- package/mission-control/src/app/api/mesh/tasks/route.ts +91 -0
- package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +1 -1
- package/mission-control/src/app/api/tasks/[id]/route.ts +90 -4
- package/mission-control/src/app/api/tasks/route.ts +21 -30
- package/mission-control/src/app/cowork/page.tsx +261 -0
- package/mission-control/src/app/diagnostics/page.tsx +385 -0
- package/mission-control/src/app/graph/page.tsx +26 -0
- package/mission-control/src/app/memory/page.tsx +1 -1
- package/mission-control/src/app/obsidian/page.tsx +36 -6
- package/mission-control/src/app/roadmap/page.tsx +24 -0
- package/mission-control/src/app/souls/page.tsx +2 -2
- package/mission-control/src/components/board/execution-config.tsx +431 -0
- package/mission-control/src/components/board/kanban-board.tsx +75 -9
- package/mission-control/src/components/board/kanban-column.tsx +135 -19
- package/mission-control/src/components/board/task-card.tsx +55 -2
- package/mission-control/src/components/board/unified-task-dialog.tsx +82 -4
- package/mission-control/src/components/cowork/cluster-card.tsx +176 -0
- package/mission-control/src/components/cowork/create-cluster-dialog.tsx +251 -0
- package/mission-control/src/components/cowork/dispatch-form.tsx +423 -0
- package/mission-control/src/components/cowork/role-picker.tsx +102 -0
- package/mission-control/src/components/cowork/session-card.tsx +284 -0
- package/mission-control/src/components/layout/sidebar.tsx +39 -2
- package/mission-control/src/lib/__tests__/daily-log.test.ts +82 -0
- package/mission-control/src/lib/__tests__/memory-md.test.ts +87 -0
- package/mission-control/src/lib/__tests__/mesh-kv-sync.test.ts +465 -0
- package/mission-control/src/lib/__tests__/mocks/mock-kv.ts +131 -0
- package/mission-control/src/lib/__tests__/status-kanban.test.ts +46 -0
- package/mission-control/src/lib/__tests__/task-markdown.test.ts +188 -0
- package/mission-control/src/lib/__tests__/wikilinks.test.ts +175 -0
- package/mission-control/src/lib/config.ts +58 -0
- package/mission-control/src/lib/db/index.ts +69 -0
- package/mission-control/src/lib/db/schema.ts +61 -3
- package/mission-control/src/lib/hooks.ts +309 -0
- package/mission-control/src/lib/memory/entities.ts +3 -2
- package/mission-control/src/lib/nats.ts +66 -1
- package/mission-control/src/lib/parsers/task-markdown.ts +52 -2
- package/mission-control/src/lib/parsers/transcript.ts +4 -4
- package/mission-control/src/lib/scheduler.ts +12 -11
- package/mission-control/src/lib/sync/mesh-kv.ts +279 -0
- package/mission-control/src/lib/sync/tasks.ts +23 -1
- package/mission-control/src/lib/task-id.ts +32 -0
- package/mission-control/src/lib/tts/index.ts +33 -9
- package/mission-control/tsconfig.json +2 -1
- package/mission-control/vitest.config.ts +14 -0
- package/package.json +15 -2
- package/services/service-manifest.json +1 -1
- package/skills/cc-godmode/references/agents.md +8 -8
- package/workspace-bin/memory-daemon.mjs +199 -5
- package/workspace-bin/session-search.mjs +204 -0
- package/workspace-bin/web-fetch.mjs +65 -0
|
@@ -1,10 +1,34 @@
|
|
|
1
|
-
import { getNats, sc } from "@/lib/nats";
|
|
1
|
+
import { getNats, getTasksKv, sc } from "@/lib/nats";
|
|
2
2
|
|
|
3
3
|
export const dynamic = "force-dynamic";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* MANUAL VERIFICATION PROTOCOL (SSE dual-iterator cleanup):
|
|
7
|
+
*
|
|
8
|
+
* The zombie watcher leak is the highest-risk bug in this route.
|
|
9
|
+
* It cannot be unit tested (no real HTTP server in Vitest).
|
|
10
|
+
*
|
|
11
|
+
* To verify after deployment:
|
|
12
|
+
* 1. Open MC on worker node in browser
|
|
13
|
+
* 2. Open DevTools > Network tab > filter "events" (SSE stream)
|
|
14
|
+
* 3. Trigger a task state change on the lead (create/complete a task)
|
|
15
|
+
* 4. Verify the SSE event arrives in the stream ("kv.task.updated" event type)
|
|
16
|
+
* 5. Close the browser tab
|
|
17
|
+
* 6. Check NATS connections: curl http://localhost:8222/connz | jq '.connections | length'
|
|
18
|
+
* -> Connection count should drop by 1 (the SSE watcher + sub are cleaned up)
|
|
19
|
+
* 7. Repeat with 3 tabs open -> close all -> verify count drops by 3
|
|
20
|
+
*
|
|
21
|
+
* If connection count doesn't drop: the cancel() handler isn't calling watcher.stop()
|
|
22
|
+
* or sub.unsubscribe(). Check the dual-iterator lifecycle in this file.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* SSE endpoint: dual-iterator (NATS pub/sub + KV watcher).
|
|
27
|
+
*
|
|
28
|
+
* Iterator 1: NATS subscription on mesh.events.> (task lifecycle events)
|
|
29
|
+
* Iterator 2: KV watcher on MESH_TASKS (real-time task state changes)
|
|
30
|
+
*
|
|
31
|
+
* Both feed into a single SSE stream. On client disconnect, both are cleaned up.
|
|
8
32
|
*/
|
|
9
33
|
export async function GET() {
|
|
10
34
|
const nc = await getNats();
|
|
@@ -13,16 +37,17 @@ export async function GET() {
|
|
|
13
37
|
}
|
|
14
38
|
|
|
15
39
|
const sub = nc.subscribe("mesh.events.>");
|
|
40
|
+
const kv = await getTasksKv();
|
|
41
|
+
let watcher: any = null;
|
|
16
42
|
let closed = false;
|
|
17
43
|
|
|
18
44
|
const stream = new ReadableStream({
|
|
19
45
|
async start(controller) {
|
|
20
46
|
const encoder = new TextEncoder();
|
|
21
47
|
|
|
22
|
-
// Send initial keepalive
|
|
23
48
|
controller.enqueue(encoder.encode(": connected\n\n"));
|
|
24
49
|
|
|
25
|
-
// Keepalive every 30s
|
|
50
|
+
// Keepalive every 30s
|
|
26
51
|
const keepalive = setInterval(() => {
|
|
27
52
|
if (!closed) {
|
|
28
53
|
try {
|
|
@@ -33,26 +58,77 @@ export async function GET() {
|
|
|
33
58
|
}
|
|
34
59
|
}, 30000);
|
|
35
60
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
61
|
+
// Iterator 1: NATS pub/sub events
|
|
62
|
+
(async () => {
|
|
63
|
+
try {
|
|
64
|
+
for await (const msg of sub) {
|
|
65
|
+
if (closed) break;
|
|
66
|
+
try {
|
|
67
|
+
const data = sc.decode(msg.data);
|
|
68
|
+
const eventType = msg.subject.replace("mesh.events.", "");
|
|
69
|
+
controller.enqueue(
|
|
70
|
+
encoder.encode(`event: ${eventType}\ndata: ${data}\n\n`)
|
|
71
|
+
);
|
|
72
|
+
} catch {
|
|
73
|
+
// skip malformed
|
|
74
|
+
}
|
|
47
75
|
}
|
|
76
|
+
} catch {
|
|
77
|
+
// subscription ended
|
|
78
|
+
}
|
|
79
|
+
})();
|
|
80
|
+
|
|
81
|
+
// Iterator 2: KV watcher (task state changes)
|
|
82
|
+
if (kv) {
|
|
83
|
+
try {
|
|
84
|
+
watcher = await kv.watch();
|
|
85
|
+
(async () => {
|
|
86
|
+
try {
|
|
87
|
+
for await (const entry of watcher) {
|
|
88
|
+
if (closed) break;
|
|
89
|
+
try {
|
|
90
|
+
const task = entry.value
|
|
91
|
+
? JSON.parse(sc.decode(entry.value))
|
|
92
|
+
: null;
|
|
93
|
+
const payload = JSON.stringify({
|
|
94
|
+
key: entry.key,
|
|
95
|
+
operation: entry.value ? "PUT" : "DEL",
|
|
96
|
+
task,
|
|
97
|
+
revision: entry.revision,
|
|
98
|
+
});
|
|
99
|
+
controller.enqueue(
|
|
100
|
+
encoder.encode(`event: kv.task.updated\ndata: ${payload}\n\n`)
|
|
101
|
+
);
|
|
102
|
+
} catch {
|
|
103
|
+
// skip malformed
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// watcher ended
|
|
108
|
+
}
|
|
109
|
+
})();
|
|
110
|
+
} catch {
|
|
111
|
+
// KV watch failed — continue with pub/sub only
|
|
48
112
|
}
|
|
49
|
-
} finally {
|
|
50
|
-
clearInterval(keepalive);
|
|
51
113
|
}
|
|
114
|
+
|
|
115
|
+
// Wait for cancel (client disconnect)
|
|
116
|
+
await new Promise<void>((resolve) => {
|
|
117
|
+
const check = setInterval(() => {
|
|
118
|
+
if (closed) {
|
|
119
|
+
clearInterval(check);
|
|
120
|
+
clearInterval(keepalive);
|
|
121
|
+
resolve();
|
|
122
|
+
}
|
|
123
|
+
}, 1000);
|
|
124
|
+
});
|
|
52
125
|
},
|
|
53
126
|
cancel() {
|
|
54
127
|
closed = true;
|
|
55
128
|
sub.unsubscribe();
|
|
129
|
+
if (watcher && typeof watcher.stop === "function") {
|
|
130
|
+
watcher.stop();
|
|
131
|
+
}
|
|
56
132
|
},
|
|
57
133
|
});
|
|
58
134
|
|
|
@@ -61,7 +137,7 @@ export async function GET() {
|
|
|
61
137
|
"Content-Type": "text/event-stream",
|
|
62
138
|
"Cache-Control": "no-cache, no-transform",
|
|
63
139
|
Connection: "keep-alive",
|
|
64
|
-
"X-Accel-Buffering": "no",
|
|
140
|
+
"X-Accel-Buffering": "no",
|
|
65
141
|
},
|
|
66
142
|
});
|
|
67
143
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { getTasksKv, sc } from "@/lib/nats";
|
|
2
|
+
import { NODE_ID, NODE_ROLE } from "@/lib/config";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* GET /api/mesh/tasks/:id — Get a single task from KV.
|
|
8
|
+
*/
|
|
9
|
+
export async function GET(
|
|
10
|
+
_req: Request,
|
|
11
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
12
|
+
) {
|
|
13
|
+
const { id } = await params;
|
|
14
|
+
const kv = await getTasksKv();
|
|
15
|
+
if (!kv) {
|
|
16
|
+
return Response.json({ error: "NATS unavailable" }, { status: 503 });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const entry = await kv.get(id);
|
|
20
|
+
if (!entry?.value) {
|
|
21
|
+
return Response.json({ error: "not found" }, { status: 404 });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const task = JSON.parse(sc.decode(entry.value));
|
|
25
|
+
return Response.json({ task, revision: entry.revision });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* PATCH /api/mesh/tasks/:id — Update a task with CAS.
|
|
30
|
+
*
|
|
31
|
+
* Authority rules:
|
|
32
|
+
* - Lead can update any task
|
|
33
|
+
* - Workers can only update tasks they originated
|
|
34
|
+
* - Revision must match (CAS) to prevent stale writes
|
|
35
|
+
*/
|
|
36
|
+
export async function PATCH(
|
|
37
|
+
req: Request,
|
|
38
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
39
|
+
) {
|
|
40
|
+
const { id } = await params;
|
|
41
|
+
const kv = await getTasksKv();
|
|
42
|
+
if (!kv) {
|
|
43
|
+
return Response.json({ error: "NATS unavailable" }, { status: 503 });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const body = await req.json();
|
|
47
|
+
const { revision, ...updates } = body;
|
|
48
|
+
|
|
49
|
+
if (!revision) {
|
|
50
|
+
return Response.json({ error: "revision is required for CAS update" }, { status: 400 });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Read current state
|
|
54
|
+
const entry = await kv.get(id);
|
|
55
|
+
if (!entry?.value) {
|
|
56
|
+
return Response.json({ error: "not found" }, { status: 404 });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const current = JSON.parse(sc.decode(entry.value));
|
|
60
|
+
|
|
61
|
+
// Authority check
|
|
62
|
+
if (NODE_ROLE !== "lead" && current.origin !== NODE_ID) {
|
|
63
|
+
return Response.json(
|
|
64
|
+
{ error: "workers can only update tasks they originated" },
|
|
65
|
+
{ status: 403 }
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Merge updates
|
|
70
|
+
const updated = { ...current, ...updates };
|
|
71
|
+
|
|
72
|
+
// CAS write
|
|
73
|
+
try {
|
|
74
|
+
await kv.update(id, sc.encode(JSON.stringify(updated)), revision);
|
|
75
|
+
} catch (err: any) {
|
|
76
|
+
// Revision mismatch — return current state so client can retry
|
|
77
|
+
const freshEntry = await kv.get(id);
|
|
78
|
+
const freshTask = freshEntry?.value
|
|
79
|
+
? JSON.parse(sc.decode(freshEntry.value))
|
|
80
|
+
: null;
|
|
81
|
+
return Response.json(
|
|
82
|
+
{
|
|
83
|
+
error: `revision mismatch: expected ${revision}, got ${entry.revision}`,
|
|
84
|
+
currentTask: freshTask,
|
|
85
|
+
currentRevision: freshEntry?.revision,
|
|
86
|
+
},
|
|
87
|
+
{ status: 409 }
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return Response.json({ ok: true, task: updated });
|
|
92
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { getTasksKv, sc } from "@/lib/nats";
|
|
2
|
+
import { NODE_ID, NODE_ROLE } from "@/lib/config";
|
|
3
|
+
import crypto from "crypto";
|
|
4
|
+
|
|
5
|
+
export const dynamic = "force-dynamic";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* GET /api/mesh/tasks — List all tasks from NATS KV.
|
|
9
|
+
* Available on all nodes (read from shared KV).
|
|
10
|
+
*/
|
|
11
|
+
export async function GET() {
|
|
12
|
+
const kv = await getTasksKv();
|
|
13
|
+
if (!kv) {
|
|
14
|
+
return Response.json({ tasks: [], natsAvailable: false });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const tasks: any[] = [];
|
|
18
|
+
const keys = await kv.keys();
|
|
19
|
+
for await (const key of keys) {
|
|
20
|
+
const entry = await kv.get(key);
|
|
21
|
+
if (!entry?.value) continue;
|
|
22
|
+
try {
|
|
23
|
+
tasks.push(JSON.parse(sc.decode(entry.value)));
|
|
24
|
+
} catch {
|
|
25
|
+
// skip malformed
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
tasks.sort((a, b) => {
|
|
30
|
+
if ((b.priority || 0) !== (a.priority || 0)) return (b.priority || 0) - (a.priority || 0);
|
|
31
|
+
return new Date(a.created_at || 0).getTime() - new Date(b.created_at || 0).getTime();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return Response.json({ tasks, natsAvailable: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* POST /api/mesh/tasks — Propose a new task.
|
|
39
|
+
*
|
|
40
|
+
* On lead: creates directly with status "queued".
|
|
41
|
+
* On worker: creates with status "proposed" — daemon validates within 30s.
|
|
42
|
+
*/
|
|
43
|
+
export async function POST(req: Request) {
|
|
44
|
+
const kv = await getTasksKv();
|
|
45
|
+
if (!kv) {
|
|
46
|
+
return Response.json({ error: "NATS unavailable" }, { status: 503 });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const body = await req.json();
|
|
50
|
+
if (!body.title) {
|
|
51
|
+
return Response.json({ error: "title is required" }, { status: 400 });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const suffix = crypto.randomBytes(3).toString("hex");
|
|
55
|
+
const now = new Date();
|
|
56
|
+
const dateStr =
|
|
57
|
+
now.getFullYear().toString() +
|
|
58
|
+
(now.getMonth() + 1).toString().padStart(2, "0") +
|
|
59
|
+
now.getDate().toString().padStart(2, "0");
|
|
60
|
+
const taskId = body.task_id || `T-${dateStr}-${suffix}`;
|
|
61
|
+
|
|
62
|
+
const status = NODE_ROLE === "lead" ? "queued" : "proposed";
|
|
63
|
+
|
|
64
|
+
const task = {
|
|
65
|
+
task_id: taskId,
|
|
66
|
+
title: body.title,
|
|
67
|
+
description: body.description || "",
|
|
68
|
+
status,
|
|
69
|
+
origin: NODE_ID,
|
|
70
|
+
owner: null,
|
|
71
|
+
priority: body.priority || 0,
|
|
72
|
+
budget_minutes: body.budget_minutes || 30,
|
|
73
|
+
metric: body.metric || null,
|
|
74
|
+
success_criteria: body.success_criteria || [],
|
|
75
|
+
scope: body.scope || [],
|
|
76
|
+
tags: body.tags || [],
|
|
77
|
+
preferred_nodes: body.preferred_nodes || [],
|
|
78
|
+
exclude_nodes: body.exclude_nodes || [],
|
|
79
|
+
created_at: now.toISOString(),
|
|
80
|
+
claimed_at: null,
|
|
81
|
+
started_at: null,
|
|
82
|
+
completed_at: null,
|
|
83
|
+
last_activity: null,
|
|
84
|
+
result: null,
|
|
85
|
+
attempts: [],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
await kv.put(taskId, sc.encode(JSON.stringify(task)));
|
|
89
|
+
|
|
90
|
+
return Response.json({ ok: true, task }, { status: 201 });
|
|
91
|
+
}
|
|
@@ -39,7 +39,7 @@ export async function POST(
|
|
|
39
39
|
return NextResponse.json({ error: "Task not found" }, { status: 404 });
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
const fromSoul = task.soulId || "
|
|
42
|
+
const fromSoul = task.soulId || "main-agent";
|
|
43
43
|
|
|
44
44
|
// Create handoff document in ClawVault
|
|
45
45
|
await fs.mkdir(HANDOFFS_DIR, { recursive: true });
|
|
@@ -6,6 +6,8 @@ import { statusToKanban, kanbanToStatus } from "@/lib/parsers/task-markdown";
|
|
|
6
6
|
import { syncTasksToMarkdown } from "@/lib/sync/tasks";
|
|
7
7
|
import { logActivity } from "@/lib/activity";
|
|
8
8
|
import { gatewayNotify } from "@/lib/gateway-notify";
|
|
9
|
+
import { AGENT_NAME, HUMAN_NAME } from "@/lib/config";
|
|
10
|
+
import { getNats, sc } from "@/lib/nats";
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* Push a notification message to the OpenClaw TUI via gateway chat.send + abort.
|
|
@@ -17,6 +19,22 @@ function notifyAgent(text: string) {
|
|
|
17
19
|
});
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Cancel a running mesh task via NATS. Best-effort — ignores failures.
|
|
24
|
+
*/
|
|
25
|
+
async function cancelMeshTask(taskId: string, execution: string | null, status: string) {
|
|
26
|
+
if (execution === "mesh" && status !== "done" && status !== "cancelled") {
|
|
27
|
+
const nc = await getNats();
|
|
28
|
+
if (nc) {
|
|
29
|
+
nc.request(
|
|
30
|
+
"mesh.tasks.cancel",
|
|
31
|
+
sc.encode(JSON.stringify({ task_id: taskId })),
|
|
32
|
+
{ timeout: 5000 }
|
|
33
|
+
).catch(() => {});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
20
38
|
/**
|
|
21
39
|
* DELETE /api/tasks/[id]
|
|
22
40
|
* Delete a task (and its children) by ID.
|
|
@@ -58,6 +76,9 @@ export async function DELETE(
|
|
|
58
76
|
collectDescendants(id);
|
|
59
77
|
idsToDelete.push(id); // add the root task last
|
|
60
78
|
|
|
79
|
+
// Cancel mesh task if running
|
|
80
|
+
await cancelMeshTask(id, existing.execution, existing.status);
|
|
81
|
+
|
|
61
82
|
// Delete all collected tasks + their dependency edges
|
|
62
83
|
for (const delId of idsToDelete) {
|
|
63
84
|
db.delete(dependencies)
|
|
@@ -109,6 +130,22 @@ export async function PATCH(
|
|
|
109
130
|
const body = await request.json();
|
|
110
131
|
const now = new Date().toISOString();
|
|
111
132
|
|
|
133
|
+
// Transition guard: block execution mode changes on in-flight mesh tasks
|
|
134
|
+
if (body.execution !== undefined && body.execution !== existing.execution) {
|
|
135
|
+
if (existing.meshTaskId) {
|
|
136
|
+
return NextResponse.json(
|
|
137
|
+
{ error: "Cannot change execution mode after task has been submitted to mesh" },
|
|
138
|
+
{ status: 400 }
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (existing.status !== "queued" && existing.status !== "ready") {
|
|
142
|
+
return NextResponse.json(
|
|
143
|
+
{ error: `Cannot change execution mode while task is ${existing.status}` },
|
|
144
|
+
{ status: 400 }
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
112
149
|
// Build the update set from provided fields
|
|
113
150
|
const update: Record<string, unknown> = { updatedAt: now };
|
|
114
151
|
|
|
@@ -130,6 +167,17 @@ export async function PATCH(
|
|
|
130
167
|
update.status = kanbanToStatus(body.kanbanColumn);
|
|
131
168
|
}
|
|
132
169
|
}
|
|
170
|
+
|
|
171
|
+
// Done-gate: only the human operator can mark tasks as done (via force_done flag).
|
|
172
|
+
// Without force_done, redirect done→review so tasks land in waiting-user.
|
|
173
|
+
const targetStatus = update.status as string | undefined;
|
|
174
|
+
const targetColumn = update.kanbanColumn as string | undefined;
|
|
175
|
+
if (!body.force_done) {
|
|
176
|
+
if (targetStatus === "done" || targetColumn === "done") {
|
|
177
|
+
update.status = "waiting-user";
|
|
178
|
+
update.kanbanColumn = "review";
|
|
179
|
+
}
|
|
180
|
+
}
|
|
133
181
|
if (body.owner !== undefined) {
|
|
134
182
|
update.owner = body.owner || null;
|
|
135
183
|
}
|
|
@@ -203,18 +251,56 @@ export async function PATCH(
|
|
|
203
251
|
if (body.acknowledged_at !== undefined) {
|
|
204
252
|
update.acknowledgedAt = body.acknowledged_at || null;
|
|
205
253
|
}
|
|
254
|
+
// Mesh execution fields
|
|
255
|
+
if (body.execution !== undefined) {
|
|
256
|
+
update.execution = body.execution || null;
|
|
257
|
+
}
|
|
258
|
+
if (body.collaboration !== undefined) {
|
|
259
|
+
update.collaboration = body.collaboration ? JSON.stringify(body.collaboration) : null;
|
|
260
|
+
}
|
|
261
|
+
if (body.preferred_nodes !== undefined) {
|
|
262
|
+
update.preferredNodes = body.preferred_nodes?.length ? JSON.stringify(body.preferred_nodes) : null;
|
|
263
|
+
}
|
|
264
|
+
if (body.exclude_nodes !== undefined) {
|
|
265
|
+
update.excludeNodes = body.exclude_nodes?.length ? JSON.stringify(body.exclude_nodes) : null;
|
|
266
|
+
}
|
|
267
|
+
if (body.cluster_id !== undefined) {
|
|
268
|
+
update.clusterId = body.cluster_id || null;
|
|
269
|
+
}
|
|
270
|
+
if (body.metric !== undefined) {
|
|
271
|
+
update.metric = body.metric || null;
|
|
272
|
+
}
|
|
273
|
+
if (body.budget_minutes !== undefined) {
|
|
274
|
+
update.budgetMinutes = body.budget_minutes;
|
|
275
|
+
}
|
|
276
|
+
if (body.scope !== undefined) {
|
|
277
|
+
update.scope = body.scope?.length ? JSON.stringify(body.scope) : null;
|
|
278
|
+
}
|
|
206
279
|
|
|
207
280
|
// Auto-set owner when task moves to running manually (no explicit owner change)
|
|
208
281
|
const effectiveStatus = (update.status as string) ?? existing.status;
|
|
209
282
|
if (effectiveStatus === "running" && body.owner === undefined && !existing.owner) {
|
|
210
|
-
update.owner =
|
|
283
|
+
update.owner = HUMAN_NAME;
|
|
211
284
|
}
|
|
212
285
|
|
|
213
286
|
db.update(tasks).set(update).where(eq(tasks.id, id)).run();
|
|
214
287
|
|
|
288
|
+
// Cancel mesh task if status changed to cancelled
|
|
289
|
+
if (update.status === "cancelled") {
|
|
290
|
+
await cancelMeshTask(id, existing.execution, existing.status);
|
|
291
|
+
}
|
|
292
|
+
|
|
215
293
|
// Sync back to markdown
|
|
216
294
|
syncTasksToMarkdown(db);
|
|
217
295
|
|
|
296
|
+
// Wake bridge when execution is set to mesh (for immediate pickup)
|
|
297
|
+
const effectiveExecution = (update.execution as string) ?? existing.execution;
|
|
298
|
+
const effectiveApprovalNum = (update.needsApproval as number) ?? existing.needsApproval;
|
|
299
|
+
if (effectiveExecution === "mesh" && effectiveApprovalNum === 0) {
|
|
300
|
+
const nc = await getNats();
|
|
301
|
+
if (nc) nc.publish("mesh.bridge.wake", sc.encode(""));
|
|
302
|
+
}
|
|
303
|
+
|
|
218
304
|
// Log activity
|
|
219
305
|
const movedTo = body.kanban_column || body.kanbanColumn;
|
|
220
306
|
const parts: string[] = [];
|
|
@@ -229,14 +315,14 @@ export async function PATCH(
|
|
|
229
315
|
id
|
|
230
316
|
);
|
|
231
317
|
|
|
232
|
-
// Push notification to TUI only for
|
|
318
|
+
// Push notification to TUI only for agent autostart tasks
|
|
233
319
|
if (movedTo || body.status) {
|
|
234
320
|
const effectiveOwner = (update.owner as string) ?? existing.owner;
|
|
235
321
|
const effectiveApproval = (update.needsApproval as number) ?? existing.needsApproval;
|
|
236
|
-
const
|
|
322
|
+
const isAgent = effectiveOwner === AGENT_NAME;
|
|
237
323
|
const isAutostart = effectiveApproval === 0;
|
|
238
324
|
|
|
239
|
-
if (
|
|
325
|
+
if (isAgent && isAutostart) {
|
|
240
326
|
const fromCol = existing.kanbanColumn || existing.status;
|
|
241
327
|
const toCol = movedTo || body.status;
|
|
242
328
|
notifyAgent(`MC-Kanban: "${existing.title}" has been moved from ${fromCol} to ${toCol}`);
|
|
@@ -7,7 +7,9 @@ import { statusToKanban } from "@/lib/parsers/task-markdown";
|
|
|
7
7
|
import { syncTasksFromMarkdownIfChanged, syncTasksToMarkdown } from "@/lib/sync/tasks";
|
|
8
8
|
import { logActivity } from "@/lib/activity";
|
|
9
9
|
import { schedulerTick } from "@/lib/scheduler";
|
|
10
|
-
import {
|
|
10
|
+
import { generateTaskId } from "@/lib/task-id";
|
|
11
|
+
import { getNats, sc } from "@/lib/nats";
|
|
12
|
+
import { WORKSPACE_ROOT, AGENT_NAME } from "@/lib/config";
|
|
11
13
|
import path from "path";
|
|
12
14
|
|
|
13
15
|
/**
|
|
@@ -67,7 +69,7 @@ export async function GET(request: NextRequest) {
|
|
|
67
69
|
title: liveWork.title,
|
|
68
70
|
status: "running",
|
|
69
71
|
kanbanColumn: "in_progress",
|
|
70
|
-
owner:
|
|
72
|
+
owner: AGENT_NAME.toLowerCase(),
|
|
71
73
|
nextAction: liveWork.nextAction || null,
|
|
72
74
|
updatedAt: new Date().toISOString(),
|
|
73
75
|
createdAt: new Date().toISOString(),
|
|
@@ -186,6 +188,15 @@ export async function POST(request: NextRequest) {
|
|
|
186
188
|
isRecurring: body.is_recurring ? 1 : 0,
|
|
187
189
|
capacityClass: body.capacity_class || "normal",
|
|
188
190
|
autoPriority: body.auto_priority || 0,
|
|
191
|
+
// Execution fields
|
|
192
|
+
execution: body.execution || null,
|
|
193
|
+
collaboration: body.collaboration ? JSON.stringify(body.collaboration) : null,
|
|
194
|
+
preferredNodes: body.preferred_nodes?.length ? JSON.stringify(body.preferred_nodes) : null,
|
|
195
|
+
excludeNodes: body.exclude_nodes?.length ? JSON.stringify(body.exclude_nodes) : null,
|
|
196
|
+
clusterId: body.cluster_id || null,
|
|
197
|
+
metric: body.metric || null,
|
|
198
|
+
budgetMinutes: body.budget_minutes || 30,
|
|
199
|
+
scope: body.scope?.length ? JSON.stringify(body.scope) : null,
|
|
189
200
|
updatedAt: now.toISOString(),
|
|
190
201
|
createdAt: now.toISOString(),
|
|
191
202
|
})
|
|
@@ -200,6 +211,13 @@ export async function POST(request: NextRequest) {
|
|
|
200
211
|
// Immediately run scheduler tick so auto-start tasks dispatch without waiting for poll
|
|
201
212
|
schedulerTick();
|
|
202
213
|
|
|
214
|
+
// Wake bridge for mesh tasks
|
|
215
|
+
if (body.execution === "mesh") {
|
|
216
|
+
getNats().then((nc) => {
|
|
217
|
+
if (nc) nc.publish("mesh.bridge.wake", sc.encode(""));
|
|
218
|
+
}).catch(() => {});
|
|
219
|
+
}
|
|
220
|
+
|
|
203
221
|
const created = db
|
|
204
222
|
.select()
|
|
205
223
|
.from(tasks)
|
|
@@ -223,31 +241,4 @@ export async function POST(request: NextRequest) {
|
|
|
223
241
|
{ status: 500 }
|
|
224
242
|
);
|
|
225
243
|
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Generate a task ID in the format T-YYYYMMDD-NNN.
|
|
230
|
-
* NNN is a zero-padded sequence number based on existing tasks for that date.
|
|
231
|
-
*/
|
|
232
|
-
function generateTaskId(db: ReturnType<typeof getDb>, date: Date): string {
|
|
233
|
-
const dateStr =
|
|
234
|
-
date.getFullYear().toString() +
|
|
235
|
-
(date.getMonth() + 1).toString().padStart(2, "0") +
|
|
236
|
-
date.getDate().toString().padStart(2, "0");
|
|
237
|
-
|
|
238
|
-
const prefix = `T-${dateStr}-`;
|
|
239
|
-
|
|
240
|
-
// Find highest existing sequence number for this date prefix
|
|
241
|
-
const existing = db
|
|
242
|
-
.select({ id: tasks.id })
|
|
243
|
-
.from(tasks)
|
|
244
|
-
.all()
|
|
245
|
-
.filter((t) => t.id.startsWith(prefix))
|
|
246
|
-
.map((t) => {
|
|
247
|
-
const seq = parseInt(t.id.slice(prefix.length), 10);
|
|
248
|
-
return isNaN(seq) ? 0 : seq;
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
const nextSeq = existing.length > 0 ? Math.max(...existing) + 1 : 1;
|
|
252
|
-
return `${prefix}${nextSeq.toString().padStart(3, "0")}`;
|
|
253
|
-
}
|
|
244
|
+
}
|