taskforce-loop-engineering 0.15.13 → 0.15.14
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/CHANGELOG.md +7 -0
- package/bin/loop-engineering.mjs +15 -2
- package/docs/agent-team-backlog.json +1 -0
- package/docs/agent-team-terminal-contract.json +1 -0
- package/docs/human-gate-command.md +17 -0
- package/docs/multi-agent-control-plane.md +8 -0
- package/docs/quota-runtime-decision.md +9 -0
- package/lib/human-gate-channel-adapter.mjs +37 -0
- package/lib/human-gate-command.mjs +161 -0
- package/lib/operator-dashboard.mjs +24 -3
- package/lib/quota-runtime-decision.mjs +62 -0
- package/lib/todo-control-plane.mjs +105 -6
- package/package.json +19 -6
- package/scripts/agent-team-control-plane-self-test.mjs +29 -0
- package/scripts/agent-team-final-judgement.mjs +27 -0
- package/scripts/distribution-skill-self-test.mjs +13 -3
- package/scripts/human-gate-command-self-test.mjs +52 -0
- package/scripts/human-gate-final-judgement.mjs +30 -0
- package/scripts/live-agent-team-conformance.mjs +61 -0
- package/scripts/openclaw-doctor.mjs +8 -0
- package/scripts/openclaw-install-self-test.mjs +4 -2
- package/scripts/openclaw-install.mjs +38 -3
- package/scripts/openclaw-smoke.mjs +4 -0
- package/scripts/operator-dashboard-self-test.mjs +2 -1
- package/scripts/quota-runtime-decision-self-test.mjs +29 -0
- package/scripts/todo-control-plane-self-test.mjs +4 -1
- package/skills/taskforce-loop-engineering/SKILL.md +8 -0
|
@@ -2,6 +2,7 @@ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/p
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { inspectAction } from './action-reservations.mjs';
|
|
5
|
+
import { decideQuota } from './quota-runtime-decision.mjs';
|
|
5
6
|
|
|
6
7
|
const TODO_STATES = new Set(['runnable', 'blocked', 'claimed', 'handoff_pending', 'completed']);
|
|
7
8
|
const RISK = new Set(['low', 'medium', 'high', 'critical']);
|
|
@@ -29,7 +30,7 @@ function location(root) {
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
function emptyState() {
|
|
32
|
-
return { version:
|
|
33
|
+
return { version: 3, fencing_counter: 0, agents: {}, todos: {}, handoffs: {}, peer_messages: {}, conflicts: {}, wake_events: {}, quotas: {}, updated_at: null };
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
async function readState(file) {
|
|
@@ -61,6 +62,7 @@ async function transaction(root, operation) {
|
|
|
61
62
|
await acquire(place.lock);
|
|
62
63
|
try {
|
|
63
64
|
const state = await readState(place.file);
|
|
65
|
+
state.version = Math.max(Number(state.version ?? 1), 3); state.agents ??= {}; state.todos ??= {}; state.handoffs ??= {}; state.peer_messages ??= {}; state.conflicts ??= {}; state.wake_events ??= {}; state.quotas ??= {};
|
|
64
66
|
const result = await operation(state);
|
|
65
67
|
if (result.changed) {
|
|
66
68
|
state.updated_at = new Date().toISOString();
|
|
@@ -77,7 +79,16 @@ function event(type, todo, extra = {}) {
|
|
|
77
79
|
|
|
78
80
|
function normalizeAgent(input) {
|
|
79
81
|
const authority = strings(input.authority_grants ?? input.authorityGrants, 'authority_grants');
|
|
80
|
-
|
|
82
|
+
const maxConcurrent = Number(input.max_concurrent ?? input.maxConcurrent ?? 1000);
|
|
83
|
+
if (!Number.isInteger(maxConcurrent) || maxConcurrent < 1) throw new Error('max_concurrent must be a positive integer.');
|
|
84
|
+
return {
|
|
85
|
+
id: id(input.id ?? input.agent_id, 'agent id'), runtime: text(input.runtime ?? 'generic', 'runtime'),
|
|
86
|
+
capabilities: strings(input.capabilities, 'capabilities'), authority_grants: authority,
|
|
87
|
+
quota_grants: { ...(input.quota_grants ?? input.quotaGrants ?? {}) }, runtime_budget_limits: input.runtime_budget_limits ?? input.runtimeBudgetLimits,
|
|
88
|
+
runtime_budget_spend: input.runtime_budget_spend ?? input.runtimeBudgetSpend, max_concurrent: maxConcurrent,
|
|
89
|
+
wake: { mode: input.wake?.mode ?? 'inbox', target: input.wake?.target ?? null },
|
|
90
|
+
status: input.status ?? 'available', metadata: input.metadata ?? {}, registered_at: new Date().toISOString()
|
|
91
|
+
};
|
|
81
92
|
}
|
|
82
93
|
|
|
83
94
|
export async function registerAgent(root, input) {
|
|
@@ -125,6 +136,7 @@ export async function createTodo(root, input) {
|
|
|
125
136
|
|
|
126
137
|
function capabilityEligible(agent, todo) { return todo.required_capabilities.every((item) => agent.capabilities.includes(item)); }
|
|
127
138
|
function authorityEligible(agent, todo) { return agent.authority_grants.includes('*') || agent.authority_grants.includes(todo.authority_class); }
|
|
139
|
+
function activeLoad(state, agentId) { return Object.values(state.todos).filter((todo) => ['claimed', 'handoff_pending'].includes(todo.state) && todo.claim?.owner === agentId).length; }
|
|
128
140
|
|
|
129
141
|
async function eligibility(root, state, todo, agent, now = Date.now()) {
|
|
130
142
|
const reasons = [];
|
|
@@ -134,15 +146,102 @@ async function eligibility(root, state, todo, agent, now = Date.now()) {
|
|
|
134
146
|
if (missing.length) reasons.push(`dependencies:${missing.join(',')}`);
|
|
135
147
|
if (!capabilityEligible(agent, todo)) reasons.push('capability_mismatch');
|
|
136
148
|
if (!authorityEligible(agent, todo)) reasons.push('authority_mismatch');
|
|
149
|
+
if (agent.status !== 'available') reasons.push(`agent_status:${agent.status}`);
|
|
150
|
+
if (activeLoad(state, agent.id) >= (agent.max_concurrent ?? 1)) reasons.push('agent_at_capacity');
|
|
137
151
|
const quota = todo.cost_envelope.quota;
|
|
138
152
|
const available = Number(agent.quota_grants?.[quota] ?? state.quotas?.[quota] ?? 0);
|
|
139
|
-
|
|
153
|
+
const quotaDecision = decideQuota({
|
|
154
|
+
has_work: true,
|
|
155
|
+
limits: agent.runtime_budget_limits ?? { money_minor: available },
|
|
156
|
+
spend: agent.runtime_budget_spend ?? {},
|
|
157
|
+
request: todo.context?.runtime_budget_request ?? { money_minor: todo.cost_envelope.amount },
|
|
158
|
+
lane_id: todo.context?.lane_id,
|
|
159
|
+
lanes: todo.context?.lanes,
|
|
160
|
+
external_condition_pending: todo.context?.external_condition_pending,
|
|
161
|
+
repairable_error: todo.context?.repairable_error,
|
|
162
|
+
can_wait_for_reset: todo.context?.can_wait_for_reset
|
|
163
|
+
});
|
|
164
|
+
if (quotaDecision.decision !== 'execute') reasons.push(quotaDecision.reason.startsWith('budget_exhausted:') ? 'quota_exhausted' : `quota_decision:${quotaDecision.decision}`);
|
|
140
165
|
for (const key of todo.idempotency_keys) {
|
|
141
166
|
const action = await inspectAction(root, key);
|
|
142
167
|
if (action?.state === 'unknown') reasons.push(`action_reconciliation:${key}`);
|
|
143
168
|
if (action?.state === 'claimed' && Date.parse(action.claim?.lease_expires_at ?? '') <= now) reasons.push(`action_reconciliation:${key}`);
|
|
144
169
|
}
|
|
145
|
-
return { eligible: reasons.length === 0, reasons };
|
|
170
|
+
return { eligible: reasons.length === 0, reasons, quota_decision: quotaDecision };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function matchTodo(root, input = {}) {
|
|
174
|
+
const state = await readState(location(root).file);
|
|
175
|
+
const requested = input.todo_id ?? input.todoId;
|
|
176
|
+
const todos = requested ? [state.todos[id(requested, 'todo id')]].filter(Boolean) : Object.values(state.todos);
|
|
177
|
+
const matches = [];
|
|
178
|
+
for (const todo of todos.sort((a, b) => b.priority - a.priority || a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id))) {
|
|
179
|
+
const candidates = [];
|
|
180
|
+
for (const agent of Object.values(state.agents)) {
|
|
181
|
+
const check = await eligibility(root, state, todo, agent);
|
|
182
|
+
const load = activeLoad(state, agent.id); const capacity = agent.max_concurrent ?? 1;
|
|
183
|
+
const capabilitySurplus = agent.capabilities.filter((item) => !todo.required_capabilities.includes(item)).length;
|
|
184
|
+
candidates.push({ agent_id: agent.id, runtime: agent.runtime, eligible: check.eligible, reasons: check.reasons, load, capacity, score: check.eligible ? (load / capacity) * 100 + capabilitySurplus : null, quota_decision: check.quota_decision });
|
|
185
|
+
}
|
|
186
|
+
candidates.sort((a, b) => (a.eligible === b.eligible ? 0 : a.eligible ? -1 : 1) || (a.score ?? Infinity) - (b.score ?? Infinity) || a.agent_id.localeCompare(b.agent_id));
|
|
187
|
+
matches.push({ todo_id: todo.id, selected_agent_id: candidates.find((item) => item.eligible)?.agent_id ?? null, candidates });
|
|
188
|
+
}
|
|
189
|
+
return requested ? matches[0] ?? null : matches;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function wakeAgent(root, input) {
|
|
193
|
+
const agentId = id(input.agent_id ?? input.agentId, 'agent id');
|
|
194
|
+
return transaction(root, async (state) => {
|
|
195
|
+
const agent = state.agents[agentId]; if (!agent) throw new Error(`Agent not registered: ${agentId}`);
|
|
196
|
+
const todoId = id(input.todo_id ?? input.todoId, 'todo id'); if (!state.todos[todoId]) throw new Error(`Todo not found: ${todoId}`);
|
|
197
|
+
const wakeId = id(input.wake_id ?? input.wakeId ?? `wake:${todoId}:${agentId}:${randomUUID()}`, 'wake id');
|
|
198
|
+
const wake = { version: 1, id: wakeId, agent_id: agentId, todo_id: todoId, runtime: agent.runtime, mode: agent.wake?.mode ?? 'inbox', target: agent.wake?.target ?? null, reason: input.reason ?? 'matched_todo', state: 'pending', created_at: new Date().toISOString() };
|
|
199
|
+
state.wake_events[wakeId] = wake;
|
|
200
|
+
return { changed: true, output: wake, event: { version: 1, event_id: randomUUID(), type: 'agent_targeted_wake', agent_id: agentId, todo_id: todoId, wake_id: wakeId, at: wake.created_at } };
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function acknowledgeWake(root, input) {
|
|
205
|
+
return transaction(root, async (state) => {
|
|
206
|
+
const wake = state.wake_events[id(input.wake_id ?? input.wakeId, 'wake id')];
|
|
207
|
+
if (!wake || wake.state !== 'pending') throw new Error('Pending wake not found.');
|
|
208
|
+
if (wake.agent_id !== id(input.agent_id ?? input.agentId, 'agent id')) throw new Error('Only the targeted agent can acknowledge a wake.');
|
|
209
|
+
wake.state = 'acknowledged'; wake.acknowledged_at = new Date().toISOString();
|
|
210
|
+
return { changed: true, output: wake, event: { version: 1, event_id: randomUUID(), type: 'agent_wake_acknowledged', agent_id: wake.agent_id, todo_id: wake.todo_id, wake_id: wake.id, at: wake.acknowledged_at } };
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function sendPeerMessage(root, input) {
|
|
215
|
+
return transaction(root, async (state) => {
|
|
216
|
+
const from = id(input.from_agent_id ?? input.fromAgentId, 'from agent id'); const to = id(input.to_agent_id ?? input.toAgentId, 'to agent id');
|
|
217
|
+
if (!state.agents[from] || !state.agents[to]) throw new Error('Both peer agents must be registered.');
|
|
218
|
+
const todoId = id(input.todo_id ?? input.todoId, 'todo id'); if (!state.todos[todoId]) throw new Error(`Todo not found: ${todoId}`);
|
|
219
|
+
const messageId = id(input.message_id ?? input.messageId ?? `peer:${todoId}:${randomUUID()}`, 'message id');
|
|
220
|
+
const message = { version: 1, id: messageId, todo_id: todoId, from_agent_id: from, to_agent_id: to, kind: input.kind ?? 'collaboration', body: text(input.body, 'body'), evidence_refs: strings(input.evidence_refs ?? input.evidenceRefs, 'evidence_refs'), state: 'pending', created_at: new Date().toISOString() };
|
|
221
|
+
state.peer_messages[messageId] = message;
|
|
222
|
+
return { changed: true, output: message, event: { version: 1, event_id: randomUUID(), type: 'peer_message_created', message_id: messageId, todo_id: todoId, agent_id: to, at: message.created_at } };
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function resolveOwnershipConflict(root, input) {
|
|
227
|
+
return transaction(root, async (state) => {
|
|
228
|
+
const todo = state.todos[id(input.todo_id ?? input.todoId, 'todo id')]; if (!todo) throw new Error('Todo not found.');
|
|
229
|
+
const winner = id(input.winner_agent_id ?? input.winnerAgentId, 'winner agent id'); if (!state.agents[winner]) throw new Error('Winner agent not registered.');
|
|
230
|
+
const contenders = strings(input.contenders ?? [todo.claim?.owner, winner].filter(Boolean), 'contenders');
|
|
231
|
+
const conflictId = id(input.conflict_id ?? input.conflictId ?? `conflict:${todo.id}:${randomUUID()}`, 'conflict id');
|
|
232
|
+
const previous = todo.claim; const token = ++state.fencing_counter; const now = new Date();
|
|
233
|
+
todo.state = 'claimed'; todo.claim = { owner: winner, fencing_token: token, claimed_at: now.toISOString(), lease_expires_at: new Date(now.getTime() + Number(input.lease_ms ?? input.leaseMs ?? 60_000)).toISOString() }; todo.updated_at = now.toISOString();
|
|
234
|
+
const conflict = { version: 1, id: conflictId, todo_id: todo.id, contenders, winner_agent_id: winner, previous_owner: previous?.owner ?? null, reason: text(input.reason, 'reason'), state: 'resolved', fencing_token: token, resolved_at: now.toISOString() };
|
|
235
|
+
state.conflicts[conflictId] = conflict;
|
|
236
|
+
const audit = event('ownership_conflict_resolved', todo, { conflict_id: conflictId, contenders, winner_agent_id: winner, previous_fencing_token: previous?.fencing_token ?? null }); todo.ownership_events = [...(todo.ownership_events ?? []), audit];
|
|
237
|
+
return { changed: true, output: { conflict, todo }, event: audit };
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function teamWorkbench(root, input = {}) {
|
|
242
|
+
const state = await readState(location(root).file); const now = Number(input.now ?? Date.now());
|
|
243
|
+
const agents = Object.values(state.agents).map((agent) => ({ ...agent, load: activeLoad(state, agent.id), pending_wakes: Object.values(state.wake_events ?? {}).filter((wake) => wake.agent_id === agent.id && wake.state === 'pending').length, pending_messages: Object.values(state.peer_messages ?? {}).filter((message) => message.to_agent_id === agent.id && message.state === 'pending').length })).sort((a, b) => a.id.localeCompare(b.id));
|
|
244
|
+
const todos = Object.values(state.todos); return { version: 1, generated_at: new Date(now).toISOString(), agents, todos, handoffs: Object.values(state.handoffs ?? {}), wake_events: Object.values(state.wake_events ?? {}), peer_messages: Object.values(state.peer_messages ?? {}), conflicts: Object.values(state.conflicts ?? {}), governance: { orphan_candidates: todos.filter((todo) => todo.claim && Date.parse(todo.claim.lease_expires_at) <= now).map((todo) => todo.id), ownership_conflicts: Object.values(state.conflicts ?? {}).filter((item) => item.state !== 'resolved').map((item) => item.id), unmatched_runnable: (await matchTodo(root)).filter((item) => !item.selected_agent_id).map((item) => item.todo_id) } };
|
|
146
245
|
}
|
|
147
246
|
|
|
148
247
|
export async function listTodos(root, options = {}) {
|
|
@@ -164,7 +263,7 @@ export async function claimTodo(root, input) {
|
|
|
164
263
|
const rejected = [];
|
|
165
264
|
for (const todo of candidates) {
|
|
166
265
|
const check = await eligibility(root, state, todo, agent);
|
|
167
|
-
if (!check.eligible) { rejected.push({ todo_id: todo.id, reasons: check.reasons }); continue; }
|
|
266
|
+
if (!check.eligible) { rejected.push({ todo_id: todo.id, reasons: check.reasons, quota_decision: check.quota_decision }); continue; }
|
|
168
267
|
const now = new Date();
|
|
169
268
|
const token = ++state.fencing_counter;
|
|
170
269
|
todo.state = 'claimed'; todo.blocked_reasons = []; todo.updated_at = now.toISOString();
|
|
@@ -173,7 +272,7 @@ export async function claimTodo(root, input) {
|
|
|
173
272
|
todo.ownership_events = [...(todo.ownership_events ?? []), audit];
|
|
174
273
|
return { changed: true, output: { claimed: true, todo, fencing_token: token }, event: audit };
|
|
175
274
|
}
|
|
176
|
-
return { changed: false, output: { claimed: false, reason: rejected[0]?.reasons?.[0] ?? 'no_eligible_todo', rejected } };
|
|
275
|
+
return { changed: false, output: { claimed: false, reason: rejected[0]?.reasons?.[0] ?? 'no_eligible_todo', decision: rejected[0]?.quota_decision?.decision ?? 'silent', scheduler_hint: rejected[0]?.quota_decision?.scheduler_hint ?? null, rejected } };
|
|
177
276
|
});
|
|
178
277
|
}
|
|
179
278
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "taskforce-loop-engineering",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.14",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "OpenClaw-native loop engineering CLI, templates, and skill for verifiable agent work loops.",
|
|
6
6
|
"type": "module",
|
|
@@ -18,24 +18,30 @@
|
|
|
18
18
|
"run-loop-cron.sh": "scripts/run-loop-cron.sh"
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
|
-
"test": "npm run check && npm run check:competitive && npm run check:operator-workspace",
|
|
21
|
+
"test": "npm run check:human-gates && npm run check && npm run check:competitive && npm run check:operator-workspace",
|
|
22
22
|
"check:operator-workspace": "node --check scripts/operator-workspace-final-judgement.mjs && node --check scripts/dashboard-autostart-install.mjs && node scripts/dashboard-autostart-self-test.mjs && node scripts/operator-workspace-final-judgement.mjs",
|
|
23
23
|
"check:competitive": "node --check lib/transactional-state-kernel.mjs && node --check lib/goal-api.mjs && node scripts/competitive-acceptance.mjs",
|
|
24
|
-
"check:adapters": "node --check lib/runtime-adapter-sdk.mjs && node scripts/runtime-adapter-conformance.mjs",
|
|
24
|
+
"check:adapters": "node --check lib/runtime-adapter-sdk.mjs && node scripts/runtime-adapter-conformance.mjs && node scripts/agent-team-control-plane-self-test.mjs",
|
|
25
|
+
"check:agent-team-live": "node scripts/live-agent-team-conformance.mjs",
|
|
25
26
|
"demo:adapter": "node examples/adapter-sdk-demo.mjs",
|
|
26
27
|
"check:production-trust": "node --check lib/runtime-adapter-v1.mjs && node --check lib/durable-journal.mjs && node --check lib/execution-ledger.mjs && node --check lib/production-evidence.mjs && node --check lib/upgrade-planner.mjs && node scripts/production-acceptance.mjs",
|
|
27
28
|
"check:config-drift": "node scripts/distribution-skill-self-test.mjs && node --check scripts/config-drift-self-test.mjs && node scripts/config-drift-self-test.mjs",
|
|
28
29
|
"check:openclaw-install": "node --check scripts/openclaw-install.mjs && node --check scripts/openclaw-doctor.mjs && node --check scripts/openclaw-smoke.mjs && node --check scripts/openclaw-manage.mjs && node scripts/openclaw-install-self-test.mjs",
|
|
29
30
|
"check:hermes-install": "node --check scripts/hermes-install.mjs && node --check scripts/hermes-doctor.mjs && node --check scripts/hermes-smoke.mjs && node scripts/hermes-install-self-test.mjs",
|
|
30
31
|
"check:project-gates": "node --check lib/core.mjs && node scripts/project-gate-reconciliation-self-test.mjs",
|
|
31
|
-
"check": "
|
|
32
|
+
"check:human-gates": "node --check lib/human-gate-command.mjs && node --check lib/human-gate-channel-adapter.mjs && node scripts/human-gate-command-self-test.mjs && node scripts/human-gate-final-judgement.mjs",
|
|
33
|
+
"check": "npm run check:config-drift && npm run check:openclaw-install && npm run check:hermes-install && node --check bin/loop-engineering.mjs && node --check lib/core.mjs && node --check lib/action-reservations.mjs && node --check lib/todo-control-plane.mjs && node --check lib/quota-runtime-decision.mjs && node --check lib/operator-dashboard.mjs && node scripts/action-reservation-self-test.mjs && node scripts/todo-control-plane-self-test.mjs && node scripts/quota-runtime-decision-self-test.mjs && node scripts/operator-dashboard-self-test.mjs && node scripts/final-judgement-self-test.mjs && node scripts/route-notify-self-test.mjs && node scripts/scheduler-heartbeat-self-test.mjs && node scripts/human-gate-lifecycle-v2-self-test.mjs && node bin/loop-engineering.mjs verify --config templates/workspace-health.json --root . && node bin/loop-engineering.mjs queue-status --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs project-intake --root /tmp/loop-engineering-check --name smoke-project --brief \"Build a small website project\" --type auto --check \"npm test\" --json >/dev/null && node bin/loop-engineering.mjs project-plan --root /tmp/loop-engineering-check --project smoke-project --force --json >/dev/null && node bin/loop-engineering.mjs project-status --root /tmp/loop-engineering-check --project smoke-project --json >/dev/null && node bin/loop-engineering.mjs queue-scheduler-tick --queue smoke --root /tmp/loop-engineering-check --plan-only --force-due --json >/dev/null && node bin/loop-engineering.mjs queue-scheduler-tick --queue smoke-progress --root /tmp/loop-engineering-check --plan-only --force-due --progress-report --progress-report-when-not-due --json >/dev/null && node bin/loop-engineering.mjs workflow-metrics --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs workflow-tune-plan --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs code-queue-init --queue smoke-code --root /tmp/loop-engineering-check --force >/dev/null && node bin/loop-engineering.mjs code-worktree-list --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-task-status --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-task-dashboard --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-worktree-cleanup-plan --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-worktree-cleanup --queue smoke-code --root /tmp/loop-engineering-check --confirm-cleanup --json >/dev/null && node bin/loop-engineering.mjs code-patch-verify --patch README.md --root . --json >/dev/null && node bin/loop-engineering.mjs code-patch-apply-plan --patch README.md --json >/dev/null && node bin/loop-engineering.mjs queue-revision-ci-self-test --queue smoke-ci --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs summarize --root . --json >/dev/null && node bin/loop-engineering.mjs dashboard-health --root . --max-age-seconds 999999999 --json >/dev/null && node bin/loop-engineering.mjs doctor --root . --json >/dev/null",
|
|
32
34
|
"pack:dry": "npm pack --dry-run"
|
|
33
35
|
},
|
|
34
36
|
"exports": {
|
|
35
37
|
".": "./lib/goal-api.mjs",
|
|
36
38
|
"./goal": "./lib/goal-api.mjs",
|
|
37
39
|
"./transactional-kernel": "./lib/transactional-state-kernel.mjs",
|
|
38
|
-
"./runtime-adapter-sdk": "./lib/runtime-adapter-sdk.mjs"
|
|
40
|
+
"./runtime-adapter-sdk": "./lib/runtime-adapter-sdk.mjs",
|
|
41
|
+
"./quota": "./lib/quota-runtime-decision.mjs",
|
|
42
|
+
"./todo-control-plane": "./lib/todo-control-plane.mjs",
|
|
43
|
+
"./human-gate-command": "./lib/human-gate-command.mjs",
|
|
44
|
+
"./human-gate-channel-adapter": "./lib/human-gate-channel-adapter.mjs"
|
|
39
45
|
},
|
|
40
46
|
"engines": {
|
|
41
47
|
"node": ">=22"
|
|
@@ -69,5 +75,12 @@
|
|
|
69
75
|
"url": "https://github.com/ambitioncn/taskforce-loop-engineering/issues"
|
|
70
76
|
},
|
|
71
77
|
"homepage": "https://github.com/ambitioncn/taskforce-loop-engineering#readme",
|
|
72
|
-
"license": "Apache-2.0"
|
|
78
|
+
"license": "Apache-2.0",
|
|
79
|
+
"main": "index.js",
|
|
80
|
+
"directories": {
|
|
81
|
+
"doc": "docs",
|
|
82
|
+
"example": "examples",
|
|
83
|
+
"lib": "lib"
|
|
84
|
+
},
|
|
85
|
+
"author": ""
|
|
73
86
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { acknowledgeWake, claimTodo, createTodo, decideHandoff, handoffTodo, matchTodo, registerAgent, resolveOwnershipConflict, sendPeerMessage, teamWorkbench, wakeAgent } from '../lib/todo-control-plane.mjs';
|
|
6
|
+
|
|
7
|
+
const root = await mkdtemp(path.join(tmpdir(), 'loop-agent-team-'));
|
|
8
|
+
for (const [id, runtime, capabilities, max] of [
|
|
9
|
+
['openclaw-worker', 'openclaw', ['code', 'research'], 2],
|
|
10
|
+
['codex-worker', 'codex-cli', ['code', 'review'], 1],
|
|
11
|
+
['claude-worker', 'claude-code', ['research', 'review'], 1]
|
|
12
|
+
]) await registerAgent(root, { id, runtime, capabilities, authority_grants: ['local'], quota_grants: { default: 20 }, max_concurrent: max, wake: { mode: 'runtime-session', target: `${runtime}:session` } });
|
|
13
|
+
const make = (id, capabilities, dependencies = []) => createTodo(root, { id, title: id, required_capabilities: capabilities, dependencies, authority_class: 'local', acceptance_contract: { checks: ['verified'] }, evidence_requirements: ['artifact'], cost: 1 });
|
|
14
|
+
await make('research', ['research']); await make('implementation', ['code'], ['research']); await make('review', ['review'], ['implementation']);
|
|
15
|
+
const researchMatch = await matchTodo(root, { todoId: 'research' });
|
|
16
|
+
assert.equal(researchMatch.selected_agent_id, 'claude-worker');
|
|
17
|
+
assert.match(researchMatch.candidates.find((candidate) => candidate.agent_id === 'codex-worker').reasons.join(','), /capability_mismatch/);
|
|
18
|
+
const wake = await wakeAgent(root, { todoId: 'research', agentId: researchMatch.selected_agent_id });
|
|
19
|
+
assert.equal(wake.runtime, 'claude-code'); assert.equal((await acknowledgeWake(root, { wakeId: wake.id, agentId: 'claude-worker' })).state, 'acknowledged');
|
|
20
|
+
const researchClaim = await claimTodo(root, { todoId: 'research', agentId: 'claude-worker' }); assert.equal(researchClaim.claimed, true);
|
|
21
|
+
const peer = await sendPeerMessage(root, { todoId: 'research', fromAgentId: 'claude-worker', toAgentId: 'openclaw-worker', kind: 'request_evidence', body: 'Please validate source evidence.', evidenceRefs: ['artifact:research-plan'] }); assert.equal(peer.to_agent_id, 'openclaw-worker');
|
|
22
|
+
const handoff = await handoffTodo(root, { todoId: 'research', agentId: 'claude-worker', targetAgentId: 'openclaw-worker', fencingToken: researchClaim.fencing_token });
|
|
23
|
+
const accepted = await decideHandoff(root, { handoffId: handoff.id, agentId: 'openclaw-worker', accept: true }); assert.equal(accepted.todo.claim.owner, 'openclaw-worker');
|
|
24
|
+
const conflict = await resolveOwnershipConflict(root, { todoId: 'research', winnerAgentId: 'claude-worker', contenders: ['openclaw-worker', 'claude-worker'], reason: 'research capability and dependency ownership', leaseMs: 1000 });
|
|
25
|
+
assert.equal(conflict.todo.claim.owner, 'claude-worker'); assert.ok(conflict.todo.claim.fencing_token > accepted.todo.claim.fencing_token);
|
|
26
|
+
const workbench = await teamWorkbench(root); assert.equal(workbench.agents.length, 3); assert.equal(workbench.peer_messages.length, 1); assert.equal(workbench.conflicts.length, 1);
|
|
27
|
+
assert.ok(workbench.governance.unmatched_runnable.includes('implementation'));
|
|
28
|
+
assert.deepEqual(new Set(workbench.agents.map((agent) => agent.runtime)), new Set(['openclaw', 'codex-cli', 'claude-code']));
|
|
29
|
+
console.log(JSON.stringify({ status: 'passed', boundary: 'durable control-plane runtime identities; credential-free external effects', assertions: 15, runtimes: workbench.agents.map(({ id, runtime }) => ({ id, runtime })) }));
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
function option(name, fallback) {
|
|
6
|
+
const index = process.argv.indexOf(name);
|
|
7
|
+
return index >= 0 ? process.argv[index + 1] : fallback;
|
|
8
|
+
}
|
|
9
|
+
const contractPath = path.resolve(option('--contract', 'docs/agent-team-terminal-contract.json'));
|
|
10
|
+
const backlogPath = path.resolve(option('--backlog', 'docs/agent-team-backlog.json'));
|
|
11
|
+
const evidenceOption = option('--evidence');
|
|
12
|
+
if (!evidenceOption) throw new Error('--evidence is required');
|
|
13
|
+
const evidencePath = path.resolve(evidenceOption);
|
|
14
|
+
const outputPath = option('--output');
|
|
15
|
+
const [contract, backlog, evidence] = await Promise.all([contractPath, backlogPath, evidencePath].map(async (file) => JSON.parse(await readFile(file, 'utf8'))));
|
|
16
|
+
const runtimeNames = new Set(evidence.results?.filter((item) => item.available && item.task_probe?.passed).map((item) => item.runtime));
|
|
17
|
+
const checks = {
|
|
18
|
+
terminal_contract_complete: contract.status === 'complete' && contract.requirements.every((item) => item.status === 'done'),
|
|
19
|
+
backlog_terminal: backlog.items.every((item) => item.status === 'done'),
|
|
20
|
+
real_not_simulated: evidence.kind === 'live_agent_team_task_conformance' && evidence.simulated === false,
|
|
21
|
+
all_runtime_tasks_passed: evidence.passed === true && ['openclaw', 'codex-cli', 'claude-code'].every((runtime) => runtimeNames.has(runtime))
|
|
22
|
+
};
|
|
23
|
+
const passed = Object.values(checks).every(Boolean);
|
|
24
|
+
const judgement = { version: 1, project: contract.project, generated_at: new Date().toISOString(), passed, status: passed ? 'accepted' : 'needs_revision', checks, evidence: { contract: contractPath, backlog: backlogPath, live_runtime: evidencePath } };
|
|
25
|
+
if (outputPath) await writeFile(path.resolve(outputPath), `${JSON.stringify(judgement, null, 2)}\n`);
|
|
26
|
+
console.log(JSON.stringify(judgement, null, 2));
|
|
27
|
+
if (!passed) process.exitCode = 2;
|
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { access, readFile } from 'node:fs/promises';
|
|
3
3
|
|
|
4
|
-
const
|
|
4
|
+
const distributedSkillUrl = new URL('../skills/taskforce-loop-engineering/SKILL.md', import.meta.url);
|
|
5
|
+
const workspaceSkillUrl = new URL('../../../skills/taskforce-loop-engineering/SKILL.md', import.meta.url);
|
|
6
|
+
const skill = await readFile(distributedSkillUrl, 'utf8');
|
|
5
7
|
|
|
6
8
|
for (const forbidden of ['ironman-task-runner', 'ironman-task-runner.mjs']) {
|
|
7
9
|
assert.equal(skill.includes(forbidden), false, `distributed skill contains local dispatcher reference: ${forbidden}`);
|
|
8
10
|
}
|
|
9
11
|
|
|
10
|
-
for (const required of ['agent-tasks', 'scripts/loops/openclaw-loop.mjs', 'configs/loops/queues/']) {
|
|
12
|
+
for (const required of ['agent-tasks', 'scripts/loops/openclaw-loop.mjs', 'scripts/loops/openclaw-loop-gate.mjs', 'configs/loops/queues/', 'feishu_signature_unverified', 'ignored_untrusted_chat', 'Dashboard and chat']) {
|
|
11
13
|
assert.equal(skill.includes(required), true, `distributed skill is missing generic integration guidance: ${required}`);
|
|
12
14
|
}
|
|
13
15
|
|
|
16
|
+
try {
|
|
17
|
+
await access(workspaceSkillUrl);
|
|
18
|
+
const workspaceSkill = await readFile(workspaceSkillUrl, 'utf8');
|
|
19
|
+
assert.equal(workspaceSkill, skill, 'workspace taskforce-loop-engineering skill drifted from the distributed skill');
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
22
|
+
}
|
|
23
|
+
|
|
14
24
|
console.log('distribution skill self-test passed');
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { createHumanGate, executeGateCommand, getHumanGate, parseBoundReply } from '../lib/human-gate-command.mjs';
|
|
6
|
+
import { handleChannelGateEvent, normalizeFeishuGateEvent, renderGateForChannel } from '../lib/human-gate-channel-adapter.mjs';
|
|
7
|
+
import { createDashboardServer } from '../lib/operator-dashboard.mjs';
|
|
8
|
+
|
|
9
|
+
const root = await mkdtemp(path.join(tmpdir(), 'loop-gate-command-'));
|
|
10
|
+
const base = { project: 'p', task: 't', action: 'deploy', reason: 'release', impact: 'production', risk: { level: 'high' }, cost: { amount: 10, currency: 'CNY', budget: 100 }, evidence: ['test'], dashboard_url: 'http://127.0.0.1:4174/', expiry: '2030-01-01T00:00:00.000Z', allowed_actors: ['owner'], source_bindings: [{ channel: 'feishu', message_id: 'card-1', reply_to: 'card-1', adapter: 'feishu' }, { channel: 'dashboard', message_id: 'gate-panel', reply_to: 'gate-panel', adapter: 'dashboard' }] };
|
|
11
|
+
const command = { gate_id: 'gate_high', decision: 'approve', expected_generation: 1, actor_id: 'owner', source_channel: 'feishu', source_message_id: 'card-1', event_type: 'card_button', idempotency_key: 'event-1' };
|
|
12
|
+
try {
|
|
13
|
+
await createHumanGate(root, { ...base, gate_id: 'gate_high' });
|
|
14
|
+
const card = await renderGateForChannel(root, 'gate_high');
|
|
15
|
+
assert.deepEqual(card.buttons.map((x) => x.decision).sort(), ['approve', 'reject', 'request_revision']); assert.equal(card.fields.cost.budget, 100);
|
|
16
|
+
const first = await executeGateCommand(root, command, { now: '2029-01-01T00:00:00.000Z' }); assert.equal(first.outcome, 'confirmation_required'); assert.equal(first.resulting_generation, 2);
|
|
17
|
+
const replay = await executeGateCommand(root, command); assert.equal(replay.replayed, true); assert.equal(replay.receipt_id, first.receipt_id);
|
|
18
|
+
await assert.rejects(executeGateCommand(root, { ...command, idempotency_key: 'event-stale' }, { now: '2029-01-01T00:00:02.000Z' }), /stale_generation/);
|
|
19
|
+
assert.equal((await executeGateCommand(root, { ...command, expected_generation: 2, idempotency_key: 'event-2' }, { now: '2029-01-01T00:00:03.000Z' })).outcome, 'approved');
|
|
20
|
+
await assert.rejects(executeGateCommand(root, { ...command, expected_generation: 2, idempotency_key: 'event-after' }), /gate_already_processed/);
|
|
21
|
+
await createHumanGate(root, { ...base, gate_id: 'gate_low', action: 'local edit', risk: { level: 'low' }, confirmation_required: false });
|
|
22
|
+
for (const bad of [{ actor_id: 'intruder' }, { source_channel: 'other' }, { source_message_id: 'forward' }, { event_type: 'natural_language' }]) await assert.rejects(executeGateCommand(root, { ...command, gate_id: 'gate_low', idempotency_key: `bad-${Object.keys(bad)[0]}`, ...bad }, { now: '2029-01-01T00:00:00.000Z' }), /(unauthorized|mismatch|untrusted)/);
|
|
23
|
+
assert.equal((await handleChannelGateEvent(root, { kind: 'ordinary_message', text: '好的,同意第一个' })).outcome, 'ignored_untrusted_chat'); assert.equal(parseBoundReply('同意'), null); assert.equal(parseBoundReply('/approve gate_low').gate_id, 'gate_low');
|
|
24
|
+
assert.equal((await handleChannelGateEvent(root, { kind: 'ordinary_message', text: '/show_gate gate_low' })).outcome, 'display_only');
|
|
25
|
+
const revision = await handleChannelGateEvent(root, { kind: 'message_reply', event_id: 'rev-1', actor_id: 'owner', channel: 'feishu', card_message_id: 'card-1', reply_to: 'card-1', text: '/request_revision gate_low fix evidence', expected_generation: 1 }, { now: '2029-01-01T00:00:00.000Z' });
|
|
26
|
+
assert.equal(revision.outcome, 'revision_created'); assert.equal((await getHumanGate(root, 'gate_low')).generation, 2);
|
|
27
|
+
assert.equal(revision.synchronized_card.buttons.every((button) => button.expected_generation === 2), true);
|
|
28
|
+
await createHumanGate(root, { ...base, gate_id: 'gate_expired', expiry: '2028-01-01T00:00:00.000Z', confirmation_required: false });
|
|
29
|
+
await assert.rejects(executeGateCommand(root, { ...command, gate_id: 'gate_expired', idempotency_key: 'expired' }, { now: '2029-01-01T00:00:00.000Z' }), /gate_expired/);
|
|
30
|
+
const feishuPayload = { header: { event_id: 'fs-1' }, event: { operator: { operator_id: { open_id: 'owner' } }, context: { open_message_id: 'card-1' }, action: { value: { gate_id: 'gate_high', decision: 'approve', expected_generation: 2 } } } };
|
|
31
|
+
assert.throws(() => normalizeFeishuGateEvent(feishuPayload), /feishu_signature_unverified/);
|
|
32
|
+
const feishu = normalizeFeishuGateEvent(feishuPayload, { signatureVerified: true }); assert.equal(feishu.kind, 'card_button');
|
|
33
|
+
await createHumanGate(root, { ...base, gate_id: 'gate_race', confirmation_required: false });
|
|
34
|
+
const raceBase = { ...command, gate_id: 'gate_race', decision: 'reject', expected_generation: 1 };
|
|
35
|
+
const race = await Promise.allSettled([executeGateCommand(root, { ...raceBase, idempotency_key: 'race-a' }), executeGateCommand(root, { ...raceBase, idempotency_key: 'race-b' })]);
|
|
36
|
+
assert.equal(race.filter((x) => x.status === 'fulfilled').length, 1); assert.equal(race.filter((x) => x.status === 'rejected').length, 1);
|
|
37
|
+
await createHumanGate(root, { ...base, gate_id: 'gate_cross', confirmation_required: false });
|
|
38
|
+
const cross = await Promise.allSettled([
|
|
39
|
+
executeGateCommand(root, { ...command, gate_id: 'gate_cross', idempotency_key: 'cross-feishu' }),
|
|
40
|
+
executeGateCommand(root, { ...command, gate_id: 'gate_cross', source_channel: 'dashboard', source_message_id: 'gate-panel', idempotency_key: 'cross-dashboard' })
|
|
41
|
+
]);
|
|
42
|
+
assert.equal(cross.filter((x) => x.status === 'fulfilled').length, 1);
|
|
43
|
+
await createHumanGate(root, { ...base, gate_id: 'gate_http', confirmation_required: false });
|
|
44
|
+
const server = await createDashboardServer(root, { host: '127.0.0.1', port: 0 });
|
|
45
|
+
try {
|
|
46
|
+
const address = server.address();
|
|
47
|
+
const response = await fetch(`http://127.0.0.1:${address.port}/api/v1/gate-commands`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ gate_id: 'gate_http', decision: 'approve', expected_generation: 1, actor_id: 'owner', source_message_id: 'gate-panel', idempotency_key: 'http-1' }) });
|
|
48
|
+
assert.equal(response.status, 200); assert.equal((await response.json()).outcome, 'approved');
|
|
49
|
+
const gatesResponse = await fetch(`http://127.0.0.1:${address.port}/api/v1/gates`); assert.equal(gatesResponse.status, 200); assert.ok((await gatesResponse.json()).some((g) => g.gate_id === 'gate_http'));
|
|
50
|
+
} finally { await new Promise((resolve) => server.close(resolve)); }
|
|
51
|
+
console.log('human-gate-command self-test: ok (unit, integration, replay, expiry, authorization, misrecognition, Feishu mapping, concurrency)');
|
|
52
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
const files = {
|
|
5
|
+
core: new URL('../lib/human-gate-command.mjs', import.meta.url),
|
|
6
|
+
adapter: new URL('../lib/human-gate-channel-adapter.mjs', import.meta.url),
|
|
7
|
+
dashboard: new URL('../lib/operator-dashboard.mjs', import.meta.url),
|
|
8
|
+
tests: new URL('./human-gate-command-self-test.mjs', import.meta.url),
|
|
9
|
+
docs: new URL('../docs/human-gate-command.md', import.meta.url)
|
|
10
|
+
};
|
|
11
|
+
const source = Object.fromEntries(await Promise.all(Object.entries(files).map(async ([key, file]) => [key, await readFile(file, 'utf8')])));
|
|
12
|
+
const checks = {
|
|
13
|
+
single_command_core: /executeGateCommand/.test(source.core) && /executeGateCommand/.test(source.adapter) && /executeGateCommand/.test(source.dashboard),
|
|
14
|
+
decisions: ['approve', 'reject', 'request_revision'].every((value) => source.core.includes(value)),
|
|
15
|
+
strict_binding: ['expected_generation', 'actor_unauthorized', 'source_channel_mismatch', 'source_message_mismatch', 'reply_binding_mismatch', 'gate_expired'].every((value) => source.core.includes(value)),
|
|
16
|
+
receipt_and_idempotency: /receipt_id/.test(source.core) && /idempotency_key_reused/.test(source.core),
|
|
17
|
+
cas_generation_fence: /gate_conflict_retry/.test(source.core) && /stale_generation/.test(source.core),
|
|
18
|
+
confirmation: /awaiting_confirmation/.test(source.core) && /confirmation_required/.test(source.core),
|
|
19
|
+
revision_artifact: /revisions/.test(source.core) && /supersedes_generation/.test(source.core),
|
|
20
|
+
fail_closed_chat: /ignored_untrusted_chat/.test(source.adapter) && /\/show_gate/.test(source.adapter),
|
|
21
|
+
synchronized_card: /synchronized_card/.test(source.adapter) && /disabled/.test(source.core),
|
|
22
|
+
complete_card: ['project', 'task', 'gate_id', 'action', 'reason', 'impact', 'risk', 'cost', 'evidence', 'dashboard_url', 'expiry', 'generation'].every((value) => source.core.includes(value)),
|
|
23
|
+
dashboard_buttons: /gate-commands/.test(source.dashboard) && ['approve', 'reject', 'request_revision'].every((value) => source.dashboard.includes(value)),
|
|
24
|
+
feishu_feasibility_no_send: /normalizeFeishuGateEvent/.test(source.adapter) && !/fetch\(|spawn\(|message.send/.test(source.adapter),
|
|
25
|
+
feishu_signature_boundary: /feishu_signature_unverified/.test(source.adapter) && /signatureVerified/.test(source.adapter),
|
|
26
|
+
fault_injection: ['race', 'replay', 'expired', 'intruder', 'forward', 'ordinary_message'].every((value) => source.tests.includes(value)),
|
|
27
|
+
documented_trust_boundary: /sole mutation boundary/.test(source.docs) && /no external calls/.test(source.docs) && /Ordinary chat is fail-closed/.test(source.docs)
|
|
28
|
+
};
|
|
29
|
+
for (const [name, passed] of Object.entries(checks)) assert.equal(passed, true, `terminal acceptance failed: ${name}`);
|
|
30
|
+
console.log(JSON.stringify({ outcome: 'accept', independent: true, checks: Object.keys(checks).length, accepted: Object.keys(checks), external_messages_sent: 0, online_test_residue: 0 }, null, 2));
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { writeFile } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
const specs = [
|
|
8
|
+
{ runtime: 'openclaw', executable: process.env.LOOP_OPENCLAW_BIN ?? 'openclaw' },
|
|
9
|
+
{ runtime: 'codex-cli', executable: process.env.LOOP_CODEX_BIN ?? 'codex' },
|
|
10
|
+
{ runtime: 'claude-code', executable: process.env.LOOP_CLAUDE_BIN ?? 'claude' }
|
|
11
|
+
];
|
|
12
|
+
const runTasks = process.argv.includes('--run-tasks');
|
|
13
|
+
const taskToken = `LOOP_AGENT_TEAM_CONFORMANCE_${randomUUID()}`;
|
|
14
|
+
const prompt = `Return exactly this token and nothing else: ${taskToken}`;
|
|
15
|
+
const taskArgs = {
|
|
16
|
+
openclaw: ['agent', '--agent', process.env.LOOP_OPENCLAW_AGENT ?? 'main', '--session-key', `agent:${process.env.LOOP_OPENCLAW_AGENT ?? 'main'}:loop-conformance-${randomUUID()}`, '--message', prompt, '--thinking', 'off', '--timeout', '120', '--json'],
|
|
17
|
+
'codex-cli': ['exec', '--ephemeral', '--skip-git-repo-check', '--sandbox', 'read-only', '--color', 'never', prompt],
|
|
18
|
+
'claude-code': ['--print', '--no-session-persistence', '--permission-mode', 'plan', '--tools', '', '--model', process.env.LOOP_CLAUDE_MODEL ?? 'haiku', '--max-budget-usd', process.env.LOOP_CLAUDE_MAX_BUDGET_USD ?? '0.10', prompt]
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function taskOutputMatches(runtime, stdout) {
|
|
22
|
+
if (runtime === 'openclaw') {
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(stdout);
|
|
25
|
+
const texts = parsed?.result?.payloads?.map((item) => item.text).filter(Boolean) ?? [];
|
|
26
|
+
return texts.length === 1 && texts[0].trim() === taskToken;
|
|
27
|
+
} catch { return false; }
|
|
28
|
+
}
|
|
29
|
+
const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
30
|
+
return lines.at(-1) === taskToken;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const results = specs.map((spec) => {
|
|
34
|
+
const probe = spawnSync(spec.executable, ['--version'], { encoding: 'utf8', timeout: 15_000 });
|
|
35
|
+
const available = !probe.error && probe.status === 0;
|
|
36
|
+
const result = { runtime: spec.runtime, executable: spec.executable, available, version: available ? (probe.stdout || probe.stderr).trim() : null, error: available ? null : (probe.error?.code ?? `exit_${probe.status}`), task_probe: null };
|
|
37
|
+
if (available && runTasks) {
|
|
38
|
+
const task = spawnSync(spec.executable, taskArgs[spec.runtime], { encoding: 'utf8', timeout: 180_000, maxBuffer: 4 * 1024 * 1024 });
|
|
39
|
+
const output = `${task.stdout ?? ''}\n${task.stderr ?? ''}`;
|
|
40
|
+
const tokenMatches = output.split(taskToken).length - 1;
|
|
41
|
+
const semanticMatch = taskOutputMatches(spec.runtime, task.stdout ?? '');
|
|
42
|
+
result.task_probe = {
|
|
43
|
+
attempted: true,
|
|
44
|
+
passed: !task.error && task.status === 0 && semanticMatch,
|
|
45
|
+
exit_status: task.status,
|
|
46
|
+
signal: task.signal,
|
|
47
|
+
token_match_count: tokenMatches,
|
|
48
|
+
semantic_output_match: semanticMatch,
|
|
49
|
+
error: task.error?.code ?? null,
|
|
50
|
+
output_bytes: Buffer.byteLength(output)
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
});
|
|
55
|
+
const unavailable = results.filter((item) => !item.available);
|
|
56
|
+
const failedTasks = runTasks ? results.filter((item) => !item.task_probe?.passed) : [];
|
|
57
|
+
const evidence = { version: 2, kind: runTasks ? 'live_agent_team_task_conformance' : 'live_agent_team_conformance_preflight', generated_at: new Date().toISOString(), passed: unavailable.length === 0 && failedTasks.length === 0, simulated: false, task_probe_requested: runTasks, task_contract: runTasks ? { operation: 'exact-token-response', external_delivery: false, filesystem_write_requested: false, unique_token: true } : null, results, next_action: unavailable.length ? 'install_or_bind_missing_runtime_executables_then_run_runtime_specific_task_probes' : failedTasks.length ? 'inspect_failed_runtime_task_probe' : runTasks ? 'run_full_regression_packaged_install_and_terminal_judgement' : 'rerun_with_--run-tasks' };
|
|
58
|
+
const outputIndex = process.argv.indexOf('--output');
|
|
59
|
+
if (outputIndex >= 0) await writeFile(path.resolve(process.argv[outputIndex + 1]), `${JSON.stringify(evidence, null, 2)}\n`);
|
|
60
|
+
console.log(JSON.stringify(evidence, null, 2));
|
|
61
|
+
if (!evidence.passed) process.exitCode = 2;
|
|
@@ -43,6 +43,7 @@ async function main() {
|
|
|
43
43
|
'scripts/loops/openclaw-loop-dispatch.mjs',
|
|
44
44
|
'scripts/loops/openclaw-loop.mjs',
|
|
45
45
|
'scripts/loops/openclaw-loop-notify.mjs',
|
|
46
|
+
'scripts/loops/openclaw-loop-gate.mjs',
|
|
46
47
|
'AGENTS.md'
|
|
47
48
|
];
|
|
48
49
|
const systemdUserDir = path.join(process.env.XDG_CONFIG_HOME || path.join(process.env.HOME || '', '.config'), 'systemd', 'user');
|
|
@@ -73,6 +74,13 @@ async function main() {
|
|
|
73
74
|
});
|
|
74
75
|
checks.push({ id: 'notification_dry_run', ok: smoke.code === 0, detail: (smoke.stdout || smoke.stderr).trim().slice(0, 500) });
|
|
75
76
|
}
|
|
77
|
+
const gateBridge = path.join(args.root, 'scripts/loops/openclaw-loop-gate.mjs');
|
|
78
|
+
if (await present(gateBridge)) {
|
|
79
|
+
const syntax = await run(process.execPath, ['--check', gateBridge], { cwd: args.root });
|
|
80
|
+
checks.push({ id: 'human_gate_bridge_syntax', ok: syntax.code === 0, detail: (syntax.stderr || syntax.stdout).trim().slice(0, 500) });
|
|
81
|
+
const selfTest = await run(process.execPath, [gateBridge, '--self-test'], { cwd: args.root, env: { ...process.env, LOOP_WORKSPACE_ROOT: args.root } });
|
|
82
|
+
checks.push({ id: 'human_gate_bridge_self_test', ok: selfTest.code === 0 && /"externalWrite":false/.test(selfTest.stdout), detail: (selfTest.stdout || selfTest.stderr).trim().slice(0, 500) });
|
|
83
|
+
}
|
|
76
84
|
const failed = checks.filter((check) => !check.ok);
|
|
77
85
|
const report = { version: 1, status: failed.length ? 'fail' : 'ok', readOnly: true, externalWrite: false, root: args.root, queue: args.queue, workerAgent: args.workerAgent, checks, failed: failed.map((check) => check.id) };
|
|
78
86
|
console.log(args.json ? JSON.stringify(report, null, 2) : `OpenClaw Loop doctor: ${report.status}\nchecks: ${checks.length - failed.length}/${checks.length}\nfailed: ${report.failed.join(', ') || 'none'}`);
|
|
@@ -104,6 +104,8 @@ if (schedulerTick.code !== 0) throw new Error(`installed scheduler tick failed:
|
|
|
104
104
|
const schedulerState = JSON.parse(await readFile(path.join(root, 'runtime/loops/test-tasks/scheduler/state.json'), 'utf8'));
|
|
105
105
|
if (!schedulerState.generatedAt || !schedulerState.nextRunAt) throw new Error('installed scheduler tick did not persist its heartbeat and cadence');
|
|
106
106
|
const notifier = await readFile(path.join(root, 'scripts/loops/openclaw-loop-notify.mjs'), 'utf8');
|
|
107
|
+
const gateBridge = await readFile(path.join(root, 'scripts/loops/openclaw-loop-gate.mjs'), 'utf8');
|
|
108
|
+
if (!gateBridge.includes('feishu_signature_unverified') || !gateBridge.includes('ignored_untrusted_chat') || !gateBridge.includes('handleChannelGateEvent')) throw new Error('installed Human Gate bridge is incomplete');
|
|
107
109
|
if (!notifier.includes("'message', 'send'") || !notifier.includes('source.channel') || !notifier.includes('source.target')) throw new Error('channel-neutral notifier missing');
|
|
108
110
|
const delivery = await new Promise((resolve) => {
|
|
109
111
|
const child = spawn(process.execPath, [path.join(root, 'scripts/loops/openclaw-loop-notify.mjs'), 'async result'], {
|
|
@@ -129,7 +131,7 @@ const doctorResult = await new Promise((resolve) => {
|
|
|
129
131
|
});
|
|
130
132
|
if (doctorResult.code !== 0) throw new Error(`doctor failed: ${doctorResult.stderr}`);
|
|
131
133
|
const doctorReport = JSON.parse(doctorResult.stdout);
|
|
132
|
-
if (doctorReport.status !== 'ok' || doctorReport.externalWrite !== false || !doctorReport.checks.some((check) => check.id === 'notification_dry_run' && check.ok)) throw new Error('doctor did not complete
|
|
134
|
+
if (doctorReport.status !== 'ok' || doctorReport.externalWrite !== false || !doctorReport.checks.some((check) => check.id === 'notification_dry_run' && check.ok) || !doctorReport.checks.some((check) => check.id === 'human_gate_bridge_self_test' && check.ok)) throw new Error('doctor did not complete safe notification and Human Gate self-tests');
|
|
133
135
|
const smoke = new URL('./openclaw-smoke.mjs', import.meta.url).pathname;
|
|
134
136
|
const smokeSource = await readFile(smoke, 'utf8');
|
|
135
137
|
if (!smokeSource.includes('Do not change user or project files, configuration, credentials, or external state.')
|
|
@@ -151,7 +153,7 @@ const smokeReport = JSON.parse(smokeResult.stdout);
|
|
|
151
153
|
if (smokeReport.status !== 'ok' || smokeReport.externalWrite !== false || !smokeReport.steps.every((step) => step.ok)) throw new Error('end-to-end smoke did not pass safely');
|
|
152
154
|
try { await readFile(path.join(root, `configs/loops/queues/${smokeReport.smokeQueue}.json`)); throw new Error('smoke config was not cleaned'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
153
155
|
try { await readFile(path.join(root, `runtime/loops/${smokeReport.smokeQueue}/state.json`)); throw new Error('smoke runtime was not cleaned'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
154
|
-
for (const generated of ['scripts/loops/openclaw-loop-dispatch.mjs', 'scripts/loops/openclaw-loop.mjs', 'scripts/loops/openclaw-loop-notify.mjs']) {
|
|
156
|
+
for (const generated of ['scripts/loops/openclaw-loop-dispatch.mjs', 'scripts/loops/openclaw-loop.mjs', 'scripts/loops/openclaw-loop-notify.mjs', 'scripts/loops/openclaw-loop-gate.mjs']) {
|
|
155
157
|
const syntax = await run(['--help']);
|
|
156
158
|
if (syntax.code !== 0) throw new Error(`installer help failed while checking ${generated}`);
|
|
157
159
|
const check = await new Promise((resolve) => {
|