taskforce-loop-engineering 0.9.1 → 0.12.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/CHANGELOG.md +23 -0
- package/MIGRATING.md +47 -2
- package/README.md +81 -7
- package/bin/loop-engineering.mjs +181 -0
- package/docs/architecture.md +444 -0
- package/docs/multi-agent-control-plane.md +31 -0
- package/docs/operator-dashboard.md +27 -0
- package/docs/release-0.12-acceptance.md +35 -0
- package/lib/action-reservations.mjs +196 -0
- package/lib/core.mjs +230 -14
- package/lib/operator-dashboard.mjs +198 -0
- package/lib/todo-control-plane.mjs +287 -0
- package/package.json +3 -2
- package/scripts/action-reservation-self-test.mjs +65 -0
- package/scripts/hermes-install-self-test.mjs +2 -0
- package/scripts/hermes-install.mjs +40 -25
- package/scripts/human-gate-lifecycle-v2-self-test.mjs +81 -0
- package/scripts/openclaw-install-self-test.mjs +19 -5
- package/scripts/openclaw-install.mjs +101 -37
- package/scripts/openclaw-manage.mjs +1 -1
- package/scripts/operator-dashboard-self-test.mjs +74 -0
- package/scripts/route-notify-self-test.mjs +7 -0
- package/scripts/todo-control-plane-self-test.mjs +74 -0
- package/templates/operator-projection.schema.json +1 -0
- package/templates/todo.schema.json +28 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const ACTION_KINDS = new Set(['paid_api', 'notification', 'deployment', 'process_control', 'publication', 'external_message', 'gated_mutation']);
|
|
6
|
+
const TERMINAL_STATES = new Set(['settled', 'released']);
|
|
7
|
+
|
|
8
|
+
function requireText(value, label) {
|
|
9
|
+
if (typeof value !== 'string' || value.trim() === '') throw new Error(`${label} must be a non-empty string.`);
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function canonical(value) {
|
|
14
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
15
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function actionRequestFingerprint(request, authorizationScope) {
|
|
20
|
+
requireText(authorizationScope, 'authorizationScope');
|
|
21
|
+
return createHash('sha256').update(JSON.stringify(canonical({ request, authorizationScope }))).digest('hex');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function safeKey(key) {
|
|
25
|
+
requireText(key, 'idempotencyKey');
|
|
26
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,199}$/.test(key)) throw new Error('idempotencyKey contains unsafe characters or is too long.');
|
|
27
|
+
return key;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function paths(root, key) {
|
|
31
|
+
const encoded = createHash('sha256').update(safeKey(key)).digest('hex');
|
|
32
|
+
const dir = path.join(root, 'runtime', 'loops', 'action-reservations');
|
|
33
|
+
return { dir, file: path.join(dir, `${encoded}.json`), lock: path.join(dir, `${encoded}.lock`) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function readJson(file) {
|
|
37
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function atomicWrite(file, value) {
|
|
41
|
+
const temp = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
42
|
+
await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' });
|
|
43
|
+
await rename(temp, file);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function acquireMutex(lock, timeoutMs = 5000) {
|
|
47
|
+
const deadline = Date.now() + timeoutMs;
|
|
48
|
+
while (true) {
|
|
49
|
+
try {
|
|
50
|
+
await mkdir(lock);
|
|
51
|
+
await writeFile(path.join(lock, 'owner.json'), JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() }));
|
|
52
|
+
return;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error.code !== 'EEXIST') throw error;
|
|
55
|
+
const info = await stat(lock).catch(() => null);
|
|
56
|
+
if (info && Date.now() - info.mtimeMs > 30_000) await rm(lock, { recursive: true, force: true });
|
|
57
|
+
else if (Date.now() >= deadline) throw new Error('Timed out acquiring action reservation mutex.');
|
|
58
|
+
else await new Promise((resolve) => setTimeout(resolve, 10));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function mutate(root, key, operation) {
|
|
64
|
+
const location = paths(root, key);
|
|
65
|
+
await mkdir(location.dir, { recursive: true });
|
|
66
|
+
await acquireMutex(location.lock);
|
|
67
|
+
try {
|
|
68
|
+
const record = await readJson(location.file).catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error));
|
|
69
|
+
const result = await operation(record, location.file);
|
|
70
|
+
if (result.write) await atomicWrite(location.file, result.record);
|
|
71
|
+
return result.output ?? result.record;
|
|
72
|
+
} finally {
|
|
73
|
+
await rm(location.lock, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function reserveAction(root, input) {
|
|
78
|
+
const key = safeKey(input.idempotencyKey);
|
|
79
|
+
const kind = requireText(input.kind, 'kind');
|
|
80
|
+
if (!ACTION_KINDS.has(kind)) throw new Error(`Unsupported action kind: ${kind}`);
|
|
81
|
+
const scope = requireText(input.authorizationScope, 'authorizationScope');
|
|
82
|
+
const fingerprint = actionRequestFingerprint(input.request, scope);
|
|
83
|
+
return mutate(root, key, async (record) => {
|
|
84
|
+
if (record) {
|
|
85
|
+
if (record.request_fingerprint !== fingerprint || record.kind !== kind || record.authorization.scope !== scope) {
|
|
86
|
+
throw new Error('Idempotency key is already bound to a different request or authorization scope.');
|
|
87
|
+
}
|
|
88
|
+
return { write: false, output: { created: false, duplicate: true, record } };
|
|
89
|
+
}
|
|
90
|
+
const now = new Date().toISOString();
|
|
91
|
+
const created = {
|
|
92
|
+
version: 1, idempotency_key: key, kind, state: 'reserved', request_fingerprint: fingerprint,
|
|
93
|
+
request: canonical(input.request), created_at: now, updated_at: now, fencing_counter: 0, claim: null,
|
|
94
|
+
authorization: { scope, state: 'reserved', reserved_at: now, consumed_at: null, released_at: null },
|
|
95
|
+
settlement: null, release: null, reconciliation: null, events: [{ type: 'reserved', at: now }]
|
|
96
|
+
};
|
|
97
|
+
return { write: true, record: created, output: { created: true, duplicate: false, record: created } };
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function inspectAction(root, idempotencyKey) {
|
|
102
|
+
const location = paths(root, idempotencyKey);
|
|
103
|
+
return readJson(location.file).catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function claimAction(root, input) {
|
|
107
|
+
const leaseMs = Number(input.leaseMs ?? 60_000);
|
|
108
|
+
if (!Number.isInteger(leaseMs) || leaseMs <= 0) throw new Error('leaseMs must be a positive integer.');
|
|
109
|
+
return mutate(root, input.idempotencyKey, async (record) => {
|
|
110
|
+
if (!record) throw new Error('Action must be reserved before it can be claimed.');
|
|
111
|
+
if (TERMINAL_STATES.has(record.state)) return { write: false, output: { claimed: false, reason: record.state, record } };
|
|
112
|
+
const nowMs = Date.now();
|
|
113
|
+
if (record.state === 'claimed') {
|
|
114
|
+
if (Date.parse(record.claim.lease_expires_at) > nowMs) return { write: false, output: { claimed: false, reason: 'lease_active', record } };
|
|
115
|
+
const at = new Date().toISOString();
|
|
116
|
+
const unknown = { ...record, state: 'unknown', updated_at: at, reconciliation: { required: true, reason: 'stale_lease', marked_at: at }, events: [...record.events, { type: 'outcome_unknown', reason: 'stale_lease', at }] };
|
|
117
|
+
return { write: true, record: unknown, output: { claimed: false, reason: 'reconcile_required', record: unknown } };
|
|
118
|
+
}
|
|
119
|
+
if (record.state === 'unknown') return { write: false, output: { claimed: false, reason: 'reconcile_required', record } };
|
|
120
|
+
const at = new Date().toISOString();
|
|
121
|
+
const token = Number(record.fencing_counter) + 1;
|
|
122
|
+
const claimed = { ...record, state: 'claimed', updated_at: at, fencing_counter: token, claim: { owner: requireText(input.owner, 'owner'), fencing_token: token, claimed_at: at, lease_expires_at: new Date(nowMs + leaseMs).toISOString() }, events: [...record.events, { type: 'claimed', fencing_token: token, at }] };
|
|
123
|
+
return { write: true, record: claimed, output: { claimed: true, fencingToken: token, record: claimed } };
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function assertFence(record, token) {
|
|
128
|
+
if (record.state !== 'claimed' || record.claim?.fencing_token !== Number(token)) throw new Error('Stale or invalid fencing token.');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function markActionUnknown(root, input) {
|
|
132
|
+
return mutate(root, input.idempotencyKey, async (record) => {
|
|
133
|
+
if (!record) throw new Error('Action reservation not found.');
|
|
134
|
+
assertFence(record, input.fencingToken);
|
|
135
|
+
const at = new Date().toISOString();
|
|
136
|
+
const next = { ...record, state: 'unknown', updated_at: at, reconciliation: { required: true, reason: input.reason ?? 'upstream_outcome_unknown', marked_at: at }, events: [...record.events, { type: 'outcome_unknown', reason: input.reason ?? 'upstream_outcome_unknown', at }] };
|
|
137
|
+
return { write: true, record: next };
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function settleAction(root, input) {
|
|
142
|
+
return mutate(root, input.idempotencyKey, async (record) => {
|
|
143
|
+
if (!record) throw new Error('Action reservation not found.');
|
|
144
|
+
if (record.state === 'settled') return { write: false, output: { settled: false, duplicate: true, record } };
|
|
145
|
+
assertFence(record, input.fencingToken);
|
|
146
|
+
const at = new Date().toISOString();
|
|
147
|
+
const next = { ...record, state: 'settled', updated_at: at, authorization: { ...record.authorization, state: 'consumed', consumed_at: at }, settlement: { status: 'succeeded', evidence: input.evidence ?? null, settled_at: at, fencing_token: Number(input.fencingToken) }, reconciliation: null, events: [...record.events, { type: 'settled', fencing_token: Number(input.fencingToken), at }] };
|
|
148
|
+
return { write: true, record: next, output: { settled: true, duplicate: false, record: next } };
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function releaseAction(root, input) {
|
|
153
|
+
return mutate(root, input.idempotencyKey, async (record) => {
|
|
154
|
+
if (!record) throw new Error('Action reservation not found.');
|
|
155
|
+
if (record.state === 'settled') throw new Error('Consumed authorization cannot be released.');
|
|
156
|
+
if (record.state === 'released') return { write: false, output: { released: false, duplicate: true, record } };
|
|
157
|
+
if (record.state === 'claimed') assertFence(record, input.fencingToken);
|
|
158
|
+
if (record.state === 'unknown') throw new Error('Unknown upstream outcome must be reconciled before release.');
|
|
159
|
+
const at = new Date().toISOString();
|
|
160
|
+
const next = { ...record, state: 'released', updated_at: at, authorization: { ...record.authorization, state: 'released', released_at: at }, release: { reason: requireText(input.reason, 'reason'), evidence: input.evidence ?? null, released_at: at }, events: [...record.events, { type: 'released', at }] };
|
|
161
|
+
return { write: true, record: next, output: { released: true, duplicate: false, record: next } };
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function reconcileAction(root, input) {
|
|
166
|
+
return mutate(root, input.idempotencyKey, async (record) => {
|
|
167
|
+
if (!record) throw new Error('Action reservation not found.');
|
|
168
|
+
if (record.state !== 'unknown') throw new Error('Only an unknown action outcome can be reconciled.');
|
|
169
|
+
if (!['accepted', 'not_accepted'].includes(input.outcome)) throw new Error('outcome must be accepted or not_accepted.');
|
|
170
|
+
const at = new Date().toISOString();
|
|
171
|
+
if (input.outcome === 'accepted') {
|
|
172
|
+
const next = { ...record, state: 'settled', updated_at: at, authorization: { ...record.authorization, state: 'consumed', consumed_at: at }, settlement: { status: 'succeeded', evidence: input.evidence ?? null, settled_at: at, reconciled: true }, reconciliation: { required: false, outcome: 'accepted', evidence: input.evidence ?? null, reconciled_at: at }, events: [...record.events, { type: 'reconciled_accepted', at }] };
|
|
173
|
+
return { write: true, record: next };
|
|
174
|
+
}
|
|
175
|
+
const next = { ...record, state: 'reserved', updated_at: at, claim: null, reconciliation: { required: false, outcome: 'not_accepted', evidence: input.evidence ?? null, reconciled_at: at }, events: [...record.events, { type: 'reconciled_not_accepted', at }] };
|
|
176
|
+
return { write: true, record: next };
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const actionAdapters = Object.freeze(Object.fromEntries(['paid_api', 'notification', 'deployment'].map((kind) => [kind, Object.freeze({
|
|
181
|
+
kind,
|
|
182
|
+
reserve: (root, input) => reserveAction(root, { ...input, kind }),
|
|
183
|
+
claim: claimAction,
|
|
184
|
+
markUnknown: markActionUnknown,
|
|
185
|
+
settle: settleAction,
|
|
186
|
+
release: releaseAction,
|
|
187
|
+
reconcile: reconcileAction,
|
|
188
|
+
inspect: inspectAction
|
|
189
|
+
})])));
|
|
190
|
+
|
|
191
|
+
export async function migrateLegacyActionArtifact(root, legacy) {
|
|
192
|
+
const key = legacy.idempotency_key ?? legacy.idempotencyKey;
|
|
193
|
+
const request = legacy.request ?? { legacy_artifact: legacy.source ?? 'unknown' };
|
|
194
|
+
const scope = legacy.authorization_scope ?? legacy.authorization?.scope ?? 'legacy:unscoped';
|
|
195
|
+
return reserveAction(root, { idempotencyKey: key, kind: legacy.kind ?? 'gated_mutation', request, authorizationScope: scope });
|
|
196
|
+
}
|
package/lib/core.mjs
CHANGED
|
@@ -947,6 +947,155 @@ export async function ensureQueueDirs(root, queue) {
|
|
|
947
947
|
.map((subdir) => mkdir(queueSubdirFor(root, queue, subdir), { recursive: true })));
|
|
948
948
|
}
|
|
949
949
|
|
|
950
|
+
const PARKED_WAIT_KINDS = new Set(['human_input', 'external_condition']);
|
|
951
|
+
|
|
952
|
+
function normalizeParkPolicy(value = {}) {
|
|
953
|
+
const positive = (input, fallback) => Number.isFinite(Number(input)) && Number(input) > 0 ? Number(input) : fallback;
|
|
954
|
+
return {
|
|
955
|
+
timeoutMs: positive(value.timeoutMs, 24 * 60 * 60 * 1000),
|
|
956
|
+
reminderIntervalMs: positive(value.reminderIntervalMs, 60 * 60 * 1000),
|
|
957
|
+
escalationIntervalMs: positive(value.escalationIntervalMs, 24 * 60 * 60 * 1000),
|
|
958
|
+
maxReminders: Math.max(0, Number.isInteger(Number(value.maxReminders)) ? Number(value.maxReminders) : 3)
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
export async function parkQueueTask(root, options = {}) {
|
|
963
|
+
const queue = normalizeLoopId(options.queue);
|
|
964
|
+
const taskId = safeTaskId(options.taskId);
|
|
965
|
+
const kind = String(options.kind ?? 'external_condition');
|
|
966
|
+
if (!PARKED_WAIT_KINDS.has(kind)) throw new Error(`Unsupported wait kind: ${kind}`);
|
|
967
|
+
const found = await findTaskFile(root, queue, taskId, ['inbox', 'active', 'waiting', 'failed']);
|
|
968
|
+
if (!found) throw new Error(`Task not found: ${taskId}`);
|
|
969
|
+
const task = await readJson(found.file);
|
|
970
|
+
if (found.subdir === 'waiting' && task.parked?.state) return { outcome: 'already_parked', task, file: path.relative(root, found.file) };
|
|
971
|
+
const now = options.now ? new Date(options.now) : new Date();
|
|
972
|
+
if (!Number.isFinite(now.getTime())) throw new Error(`Invalid park time: ${options.now}`);
|
|
973
|
+
const waitId = options.waitId ? normalizeLoopId(options.waitId) : `${taskId}.${isoStamp(now)}`;
|
|
974
|
+
const parked = {
|
|
975
|
+
version: 2,
|
|
976
|
+
wait_id: waitId,
|
|
977
|
+
kind,
|
|
978
|
+
state: kind === 'human_input' ? 'waiting_for_human' : 'external_condition_wait',
|
|
979
|
+
reason: String(options.reason ?? 'Waiting for an external condition.'),
|
|
980
|
+
parked_at: now.toISOString(),
|
|
981
|
+
policy: normalizeParkPolicy(options.policy),
|
|
982
|
+
reminder_count: 0,
|
|
983
|
+
escalation_count: 0,
|
|
984
|
+
last_notification_at: null,
|
|
985
|
+
recovery: {
|
|
986
|
+
verification_required: true,
|
|
987
|
+
signal_sha256: null,
|
|
988
|
+
verified_at: null
|
|
989
|
+
},
|
|
990
|
+
execution_boundary: {
|
|
991
|
+
key: String(options.executionKey ?? waitId),
|
|
992
|
+
action_executed: Boolean(options.actionExecuted),
|
|
993
|
+
resumed_at: null
|
|
994
|
+
},
|
|
995
|
+
authorization: options.authorization ?? task.parked?.authorization ?? null
|
|
996
|
+
};
|
|
997
|
+
const waitingFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(found.file));
|
|
998
|
+
await writeJson(waitingFile, { ...task, status: 'parked', parked });
|
|
999
|
+
if (path.resolve(found.file) !== path.resolve(waitingFile)) await rm(found.file, { force: true });
|
|
1000
|
+
return { outcome: 'parked', task: { ...task, status: 'parked', parked }, file: path.relative(root, waitingFile) };
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function parkedDisplayState(parked, now = Date.now()) {
|
|
1004
|
+
if (!parked) return 'waiting';
|
|
1005
|
+
const timedOut = now >= Date.parse(parked.parked_at) + Number(parked.policy?.timeoutMs ?? Infinity);
|
|
1006
|
+
if (timedOut || Number(parked.escalation_count ?? 0) > 0) return 'timed_out_or_escalated';
|
|
1007
|
+
return parked.state ?? (parked.kind === 'human_input' ? 'waiting_for_human' : 'external_condition_wait');
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
export async function tickParkedTasks(root, options = {}) {
|
|
1011
|
+
const queue = normalizeLoopId(options.queue);
|
|
1012
|
+
const now = options.now ? new Date(options.now) : new Date();
|
|
1013
|
+
if (!Number.isFinite(now.getTime())) throw new Error(`Invalid tick time: ${options.now}`);
|
|
1014
|
+
if (!options.notifyCommand && !options.dryRun) throw new Error('queue-wait-tick requires --notify-command unless --dry-run is used.');
|
|
1015
|
+
const evidenceDir = path.join(queueDirFor(root, queue), 'wait-notifications');
|
|
1016
|
+
await mkdir(evidenceDir, { recursive: true });
|
|
1017
|
+
const results = [];
|
|
1018
|
+
for (const file of await listJson(queueSubdirFor(root, queue, 'waiting'))) {
|
|
1019
|
+
const full = path.join(queueSubdirFor(root, queue, 'waiting'), file);
|
|
1020
|
+
const task = await readJson(full);
|
|
1021
|
+
if (!task.parked?.wait_id) continue;
|
|
1022
|
+
const parked = task.parked;
|
|
1023
|
+
const policy = normalizeParkPolicy(parked.policy);
|
|
1024
|
+
const elapsed = now.getTime() - Date.parse(parked.parked_at);
|
|
1025
|
+
const lastAt = parked.last_notification_at ? Date.parse(parked.last_notification_at) : 0;
|
|
1026
|
+
const notificationElapsed = lastAt ? now.getTime() - lastAt : Infinity;
|
|
1027
|
+
const timedOut = elapsed >= policy.timeoutMs;
|
|
1028
|
+
const type = timedOut && notificationElapsed >= policy.escalationIntervalMs
|
|
1029
|
+
? 'escalation'
|
|
1030
|
+
: Number(parked.reminder_count ?? 0) < policy.maxReminders && notificationElapsed >= policy.reminderIntervalMs
|
|
1031
|
+
? 'reminder'
|
|
1032
|
+
: null;
|
|
1033
|
+
if (!type) {
|
|
1034
|
+
results.push({ taskId: task.id, waitId: parked.wait_id, outcome: 'throttled', state: parkedDisplayState(parked, now.getTime()) });
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
const sequence = type === 'reminder' ? Number(parked.reminder_count ?? 0) + 1 : Number(parked.escalation_count ?? 0) + 1;
|
|
1038
|
+
const evidenceFile = path.join(evidenceDir, `${safeTaskId(task.id)}.${normalizeLoopId(parked.wait_id)}.${type}.${sequence}.json`);
|
|
1039
|
+
if (await exists(evidenceFile)) {
|
|
1040
|
+
results.push({ taskId: task.id, waitId: parked.wait_id, outcome: 'already_notified', type, sequence });
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
const message = `Loop task parked (${parked.kind}): ${task.title}\nreason: ${parked.reason}\nstate: ${timedOut ? 'timed_out_or_escalated' : parked.state}\nwait: ${parked.wait_id}`;
|
|
1044
|
+
if (options.dryRun) {
|
|
1045
|
+
results.push({ taskId: task.id, waitId: parked.wait_id, outcome: 'dry_run', type, sequence, message });
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
// Claim the sequence before the external send. After a crash, a later tick
|
|
1049
|
+
// observes this durable boundary and will not duplicate the notification.
|
|
1050
|
+
await writeJson(evidenceFile, { version: 1, queue, task_id: task.id, wait_id: parked.wait_id, type, sequence, status: 'sending', claimed_at: now.toISOString() });
|
|
1051
|
+
const result = await runCommand(`${options.notifyCommand} ${shellQuote(message)}`, { cwd: root, timeoutMs: options.timeoutMs ?? 60_000 });
|
|
1052
|
+
if (result.exitCode !== 0) {
|
|
1053
|
+
await writeJson(evidenceFile, { version: 1, queue, task_id: task.id, wait_id: parked.wait_id, type, sequence, status: 'failed', attempted_at: now.toISOString(), result: compactCommandResult(result) });
|
|
1054
|
+
results.push({ taskId: task.id, waitId: parked.wait_id, outcome: 'failed', type, result: compactCommandResult(result) });
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
const nextParked = {
|
|
1058
|
+
...parked,
|
|
1059
|
+
policy,
|
|
1060
|
+
state: timedOut ? 'timed_out_or_escalated' : parked.state,
|
|
1061
|
+
reminder_count: Number(parked.reminder_count ?? 0) + (type === 'reminder' ? 1 : 0),
|
|
1062
|
+
escalation_count: Number(parked.escalation_count ?? 0) + (type === 'escalation' ? 1 : 0),
|
|
1063
|
+
last_notification_at: now.toISOString()
|
|
1064
|
+
};
|
|
1065
|
+
await writeJson(evidenceFile, { version: 1, queue, task_id: task.id, wait_id: parked.wait_id, type, sequence, status: 'sent', notified_at: now.toISOString(), result: compactCommandResult(result) });
|
|
1066
|
+
await writeJson(full, { ...task, parked: nextParked });
|
|
1067
|
+
results.push({ taskId: task.id, waitId: parked.wait_id, outcome: 'sent', type, sequence, evidence: path.relative(root, evidenceFile) });
|
|
1068
|
+
}
|
|
1069
|
+
return { queue, inspected: results.length, sent: results.filter((item) => item.outcome === 'sent').length, failed: results.filter((item) => item.outcome === 'failed').length, results };
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
export async function resumeParkedTask(root, options = {}) {
|
|
1073
|
+
const queue = normalizeLoopId(options.queue);
|
|
1074
|
+
const taskId = safeTaskId(options.taskId);
|
|
1075
|
+
if (!options.verified || typeof options.recoverySignal !== 'string' || !options.recoverySignal.trim()) {
|
|
1076
|
+
throw new Error('queue-wait-resume requires --verified and --recovery-signal.');
|
|
1077
|
+
}
|
|
1078
|
+
const found = await findTaskFile(root, queue, taskId, ['waiting', 'inbox']);
|
|
1079
|
+
if (!found) throw new Error(`Parked task not found: ${taskId}`);
|
|
1080
|
+
const task = await readJson(found.file);
|
|
1081
|
+
if (found.subdir === 'inbox' && task.parked?.execution_boundary?.resumed_at) {
|
|
1082
|
+
return { outcome: 'already_resumed', task, file: path.relative(root, found.file) };
|
|
1083
|
+
}
|
|
1084
|
+
if (!task.parked?.wait_id) throw new Error(`Task is not parked: ${taskId}`);
|
|
1085
|
+
const signalSha256 = createHash('sha256').update(options.recoverySignal.trim()).digest('hex');
|
|
1086
|
+
const now = options.now ? new Date(options.now) : new Date();
|
|
1087
|
+
const resumed = {
|
|
1088
|
+
...task.parked,
|
|
1089
|
+
state: 'runnable',
|
|
1090
|
+
recovery: { ...task.parked.recovery, signal_sha256: signalSha256, verified_at: now.toISOString() },
|
|
1091
|
+
execution_boundary: { ...task.parked.execution_boundary, resumed_at: now.toISOString() }
|
|
1092
|
+
};
|
|
1093
|
+
const inboxFile = path.join(queueSubdirFor(root, queue, 'inbox'), path.basename(found.file));
|
|
1094
|
+
await writeJson(inboxFile, { ...task, status: 'queued', parked: resumed, requeuedAt: now.toISOString(), requeuedFrom: 'waiting' });
|
|
1095
|
+
if (path.resolve(found.file) !== path.resolve(inboxFile)) await rm(found.file, { force: true });
|
|
1096
|
+
return { outcome: 'verified_and_requeued', signalSha256, task: { ...task, status: 'queued', parked: resumed }, file: path.relative(root, inboxFile) };
|
|
1097
|
+
}
|
|
1098
|
+
|
|
950
1099
|
export function taskIdForTitle(title, date = new Date()) {
|
|
951
1100
|
const slug = (title || 'task')
|
|
952
1101
|
.toLowerCase()
|
|
@@ -1520,7 +1669,7 @@ export function classifyLoopMessage(message) {
|
|
|
1520
1669
|
const mentionsLoop = /(loop engineering|loop-engineering|task-runner|队列|queue|\bloop\b)/i.test(text);
|
|
1521
1670
|
const statusIntent = mentionsLoop
|
|
1522
1671
|
&& /(查|看|检查|审计|状态|情况|进度|怎么样|为什么|失败|报错|健康|health|status|progress|audit|summar)/i.test(text);
|
|
1523
|
-
const executeIntent = /(走\s*loop|继续(?:当前|这个)?\s*loop|给(?:当前|这个)?\s*loop\s*(?:补充|增加|加)|用\s*loop.*(?:解决|执行|完成|处理|修复|对齐|补齐|增强|开发|构建|绕过|避开|跳过|bypass|evade)|丢进.*loop|入队|enqueue|run[- ]?queue
|
|
1672
|
+
const executeIntent = /(走\s*loop|继续(?:当前|这个)?\s*loop|给(?:当前|这个)?\s*loop\s*(?:补充|增加|加)|用\s*loop.*(?:解决|执行|完成|处理|修复|对齐|补齐|增强|开发|构建|绕过|避开|跳过|bypass|evade)|(?:继续|开始|推进).*(?:开发|建设|完善|增强).*(?:loop engineering|loop-engineering|task-runner)|丢进.*loop|入队|enqueue|run[- ]?queue|立刻执行|立即执行|use\s+(?:loop engineering|the\s+loop)|run\s+(?:this|it|the\s+task).*(?:through|with)\s+(?:loop engineering|the\s+loop)|continue\s+(?:the\s+)?(?:current\s+)?loop|amend\s+(?:the\s+)?(?:current\s+)?loop)/i.test(text);
|
|
1524
1673
|
const intent = executeIntent ? 'execute' : statusIntent ? 'status' : 'direct';
|
|
1525
1674
|
return {
|
|
1526
1675
|
intent,
|
|
@@ -1692,9 +1841,32 @@ export async function routeLoopMessage(root, options = {}) {
|
|
|
1692
1841
|
};
|
|
1693
1842
|
}
|
|
1694
1843
|
|
|
1695
|
-
function
|
|
1844
|
+
function normalizeLanguage(value) {
|
|
1845
|
+
return value === 'zh' ? 'zh' : 'en';
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
async function installedQueueLanguage(root, queue, fallback = 'en') {
|
|
1849
|
+
const file = path.join(root, 'configs', 'loops', 'queues', `${queue}.json`);
|
|
1850
|
+
if (!await exists(file)) return normalizeLanguage(fallback);
|
|
1851
|
+
try { return normalizeLanguage((await readJson(file)).language); } catch { return normalizeLanguage(fallback); }
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
function terminalNotificationMessage(queue, task, language = 'en') {
|
|
1696
1855
|
const needsReview = task.status === 'ready_for_human_review';
|
|
1697
1856
|
const needsHuman = ['needs_human_input', 'blocked', 'ready_for_human_review'].includes(task.status);
|
|
1857
|
+
if (language === 'zh') return [
|
|
1858
|
+
needsReview ? 'Loop 任务已准备好接受人工验收'
|
|
1859
|
+
: needsHuman ? 'Loop 任务需要人工输入'
|
|
1860
|
+
: 'Loop 任务已到达终态',
|
|
1861
|
+
`任务:${task.title}`,
|
|
1862
|
+
`队列:${queue}`,
|
|
1863
|
+
`状态:${task.status}`,
|
|
1864
|
+
...(needsReview
|
|
1865
|
+
? [`下一步:检查最终判定,并为任务 ${task.id} 记录 approve、request_changes 或 reject。`]
|
|
1866
|
+
: needsHuman
|
|
1867
|
+
? ['下一步:检查任务的最终判定和检查点,解决阻塞,然后明确继续或重新入队。']
|
|
1868
|
+
: [])
|
|
1869
|
+
].join('\n');
|
|
1698
1870
|
return [
|
|
1699
1871
|
needsReview ? 'Loop task is ready for human acceptance'
|
|
1700
1872
|
: needsHuman ? 'Loop task needs human input'
|
|
@@ -1712,6 +1884,7 @@ function terminalNotificationMessage(queue, task) {
|
|
|
1712
1884
|
|
|
1713
1885
|
export async function notifyTerminalTasks(root, options = {}) {
|
|
1714
1886
|
const queue = normalizeLoopId(options.queue);
|
|
1887
|
+
const language = await installedQueueLanguage(root, queue, options.language);
|
|
1715
1888
|
if (!options.notifyCommand && !options.dryRun) {
|
|
1716
1889
|
throw new Error('queue-terminal-notify requires --notify-command unless --dry-run is used.');
|
|
1717
1890
|
}
|
|
@@ -1739,7 +1912,7 @@ export async function notifyTerminalTasks(root, options = {}) {
|
|
|
1739
1912
|
results.push({ taskId: task.id, status: task.status, outcome: 'already_notified', ledger: path.relative(root, ledgerFile) });
|
|
1740
1913
|
continue;
|
|
1741
1914
|
}
|
|
1742
|
-
const message = terminalNotificationMessage(queue, task);
|
|
1915
|
+
const message = terminalNotificationMessage(queue, task, language);
|
|
1743
1916
|
if (options.dryRun) {
|
|
1744
1917
|
results.push({ taskId: task.id, status: task.status, outcome: 'dry_run', message, source: task.source });
|
|
1745
1918
|
continue;
|
|
@@ -1783,11 +1956,19 @@ export async function notifyTerminalTasks(root, options = {}) {
|
|
|
1783
1956
|
};
|
|
1784
1957
|
}
|
|
1785
1958
|
|
|
1786
|
-
function humanInputMessage(queue, task, checkpoint, gateId) {
|
|
1959
|
+
function humanInputMessage(queue, task, checkpoint, gateId, language = 'en') {
|
|
1787
1960
|
const blockers = Array.isArray(checkpoint.blockers) ? checkpoint.blockers : [];
|
|
1788
1961
|
const blockerText = blockers.length
|
|
1789
1962
|
? blockers.map((item) => typeof item === 'string' ? item : item?.human_action_required ?? item?.user_action ?? item?.description ?? item?.message ?? JSON.stringify(item))
|
|
1790
1963
|
: [checkpoint.next_action ?? 'Human input is required before the task can continue.'];
|
|
1964
|
+
if (language === 'zh') return [
|
|
1965
|
+
'Loop 任务正在等待你的输入',
|
|
1966
|
+
`任务:${task.title}`,
|
|
1967
|
+
`队列:${queue}`,
|
|
1968
|
+
`门禁:${gateId}`,
|
|
1969
|
+
...blockerText.map((item) => `需要:${item}`),
|
|
1970
|
+
`回复:LOOP ${gateId} <你的输入>`
|
|
1971
|
+
].join('\n');
|
|
1791
1972
|
return [
|
|
1792
1973
|
'Loop task is waiting for your input',
|
|
1793
1974
|
`task: ${task.title}`,
|
|
@@ -1811,6 +1992,7 @@ async function tasksById(root, queue) {
|
|
|
1811
1992
|
|
|
1812
1993
|
export async function notifyHumanInputRequests(root, options = {}) {
|
|
1813
1994
|
const queue = normalizeLoopId(options.queue);
|
|
1995
|
+
const language = await installedQueueLanguage(root, queue, options.language);
|
|
1814
1996
|
if (!options.notifyCommand && !options.dryRun) {
|
|
1815
1997
|
throw new Error('queue-human-input-notify requires --notify-command unless --dry-run is used.');
|
|
1816
1998
|
}
|
|
@@ -1857,7 +2039,7 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
1857
2039
|
results.push({ taskId, checkpointId, gateId, outcome: gate.status === 'resolved' ? 'resolved' : 'already_notified', ledger: path.relative(root, ledgerFile) });
|
|
1858
2040
|
continue;
|
|
1859
2041
|
}
|
|
1860
|
-
const message = humanInputMessage(queue, entry.task, checkpoint, gateId);
|
|
2042
|
+
const message = humanInputMessage(queue, entry.task, checkpoint, gateId, language);
|
|
1861
2043
|
if (options.dryRun) {
|
|
1862
2044
|
results.push({ taskId, checkpointId, gateId, outcome: 'dry_run', message, source: entry.task.source });
|
|
1863
2045
|
continue;
|
|
@@ -3208,11 +3390,29 @@ export async function queueStatus(root, queue) {
|
|
|
3208
3390
|
const activeFiles = await listJson(queueSubdirFor(root, queue, 'active'));
|
|
3209
3391
|
const lock = await readQueueLock(root, queue);
|
|
3210
3392
|
const lockOwnerAlive = queueLockOwnerAlive(lock);
|
|
3393
|
+
const waitingTasks = [];
|
|
3394
|
+
for (const file of await listJson(queueSubdirFor(root, queue, 'waiting'))) {
|
|
3395
|
+
const task = await readJson(path.join(queueSubdirFor(root, queue, 'waiting'), file));
|
|
3396
|
+
waitingTasks.push({
|
|
3397
|
+
taskId: task.id,
|
|
3398
|
+
status: task.status,
|
|
3399
|
+
waitId: task.parked?.wait_id ?? task.waitingGateId ?? null,
|
|
3400
|
+
waitKind: task.parked?.kind ?? (task.status === 'waiting_for_human' ? 'human_input' : null),
|
|
3401
|
+
displayState: task.parked ? parkedDisplayState(task.parked) : task.status,
|
|
3402
|
+
parkedAt: task.parked?.parked_at ?? task.waitingSince ?? null,
|
|
3403
|
+
reminderCount: Number(task.parked?.reminder_count ?? 0),
|
|
3404
|
+
escalationCount: Number(task.parked?.escalation_count ?? 0),
|
|
3405
|
+
authorizationState: task.parked?.authorization?.state ?? null,
|
|
3406
|
+
actionExecuted: Boolean(task.parked?.execution_boundary?.action_executed)
|
|
3407
|
+
});
|
|
3408
|
+
}
|
|
3211
3409
|
return {
|
|
3212
3410
|
queue,
|
|
3213
3411
|
queued: (await listJson(queueSubdirFor(root, queue, 'inbox'))).length,
|
|
3214
3412
|
active: activeFiles.length,
|
|
3215
|
-
waiting:
|
|
3413
|
+
waiting: waitingTasks.length,
|
|
3414
|
+
waitingStates: waitingTasks.reduce((counts, task) => ({ ...counts, [task.displayState]: (counts[task.displayState] ?? 0) + 1 }), {}),
|
|
3415
|
+
waitingTasks,
|
|
3216
3416
|
done: (await listJson(queueSubdirFor(root, queue, 'done'))).length,
|
|
3217
3417
|
failed: (await listJson(queueSubdirFor(root, queue, 'failed'))).length,
|
|
3218
3418
|
canceled: (await listJson(queueSubdirFor(root, queue, 'canceled'))).length,
|
|
@@ -3458,20 +3658,32 @@ function computeQueueSchedulerInterval(previous, policy, observed) {
|
|
|
3458
3658
|
};
|
|
3459
3659
|
}
|
|
3460
3660
|
|
|
3461
|
-
function summarizeQueueCounts(status) {
|
|
3462
|
-
return
|
|
3661
|
+
function summarizeQueueCounts(status, language = 'en') {
|
|
3662
|
+
return language === 'zh'
|
|
3663
|
+
? `排队=${status.queued},执行中=${status.active},失败=${status.failed},完成=${status.done}`
|
|
3664
|
+
: `queued=${status.queued}, active=${status.active}, failed=${status.failed}, done=${status.done}`;
|
|
3463
3665
|
}
|
|
3464
3666
|
|
|
3465
3667
|
function buildQueueProgressMessage(report) {
|
|
3668
|
+
const language = normalizeLanguage(report.language);
|
|
3466
3669
|
const lines = [];
|
|
3467
3670
|
const run = report.observed.runPath ? ` run=${report.observed.runPath}` : '';
|
|
3468
3671
|
const task = report.observed.taskId ? ` task=${report.observed.taskId}` : '';
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3672
|
+
if (language === 'zh') {
|
|
3673
|
+
lines.push(`Loop 进度:${report.queue} ${report.status}${task}${run}`);
|
|
3674
|
+
lines.push(`结果:${report.outcomeGroup};下次运行 ${report.nextRunAt};间隔 ${report.currentIntervalMs}ms`);
|
|
3675
|
+
lines.push(`之前:${summarizeQueueCounts(report.statusBefore, language)}`);
|
|
3676
|
+
lines.push(`之后:${summarizeQueueCounts(report.statusAfter, language)}`);
|
|
3677
|
+
if (report.reasonSummary) lines.push(`原因:${report.reasonSummary}`);
|
|
3678
|
+
if (report.attention.length > 0) lines.push(`需要关注:${report.attention.join(', ')}`);
|
|
3679
|
+
} else {
|
|
3680
|
+
lines.push(`Loop progress: ${report.queue} ${report.status}${task}${run}`);
|
|
3681
|
+
lines.push(`Outcome: ${report.outcomeGroup}; next run ${report.nextRunAt}; interval ${report.currentIntervalMs}ms`);
|
|
3682
|
+
lines.push(`Before: ${summarizeQueueCounts(report.statusBefore)}`);
|
|
3683
|
+
lines.push(`After: ${summarizeQueueCounts(report.statusAfter)}`);
|
|
3684
|
+
if (report.reasonSummary) lines.push(`Reason: ${report.reasonSummary}`);
|
|
3685
|
+
if (report.attention.length > 0) lines.push(`Needs attention: ${report.attention.join(', ')}`);
|
|
3686
|
+
}
|
|
3475
3687
|
return lines.join('\n');
|
|
3476
3688
|
}
|
|
3477
3689
|
|
|
@@ -3566,6 +3778,7 @@ export async function queueSchedulerTick(root, options) {
|
|
|
3566
3778
|
const progressReport = {
|
|
3567
3779
|
version: 1,
|
|
3568
3780
|
queue,
|
|
3781
|
+
language: normalizeLanguage(options.language),
|
|
3569
3782
|
generatedAt: now,
|
|
3570
3783
|
status,
|
|
3571
3784
|
outcomeGroup: decision.group,
|
|
@@ -3645,6 +3858,9 @@ export async function loadQueueConfig(root, configPath) {
|
|
|
3645
3858
|
const file = path.resolve(root, safeRelativePath(configPath, 'queue config'));
|
|
3646
3859
|
const config = await readJson(file);
|
|
3647
3860
|
if (config.queue !== undefined) normalizeLoopId(config.queue);
|
|
3861
|
+
if (config.language !== undefined && !['en', 'zh'].includes(config.language)) {
|
|
3862
|
+
throw new Error('queue config language must be en or zh.');
|
|
3863
|
+
}
|
|
3648
3864
|
if (config.preflightConfig !== undefined) safeRelativePath(config.preflightConfig, 'preflight config');
|
|
3649
3865
|
if (config.timeoutMs !== undefined && !positiveInteger(config.timeoutMs)) {
|
|
3650
3866
|
throw new Error('queue config timeoutMs must be a positive integer.');
|