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.
@@ -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|立刻执行|立即执行)/i.test(text);
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 terminalNotificationMessage(queue, task) {
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: (await listJson(queueSubdirFor(root, queue, 'waiting'))).length,
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 `queued=${status.queued}, active=${status.active}, failed=${status.failed}, done=${status.done}`;
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
- lines.push(`Loop progress: ${report.queue} ${report.status}${task}${run}`);
3470
- lines.push(`Outcome: ${report.outcomeGroup}; next run ${report.nextRunAt}; interval ${report.currentIntervalMs}ms`);
3471
- lines.push(`Before: ${summarizeQueueCounts(report.statusBefore)}`);
3472
- lines.push(`After: ${summarizeQueueCounts(report.statusAfter)}`);
3473
- if (report.reasonSummary) lines.push(`Reason: ${report.reasonSummary}`);
3474
- if (report.attention.length > 0) lines.push(`Needs attention: ${report.attention.join(', ')}`);
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.');