taskforce-loop-engineering 0.10.0 → 0.13.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/MIGRATING.md +47 -2
  3. package/README.md +70 -0
  4. package/bin/loop-engineering.mjs +192 -0
  5. package/docs/architecture.md +444 -0
  6. package/docs/multi-agent-control-plane.md +31 -0
  7. package/docs/operator-dashboard.md +27 -0
  8. package/docs/production-operations.md +29 -0
  9. package/docs/production-trust-backlog.json +13 -0
  10. package/docs/production-trust-contract.md +54 -0
  11. package/docs/release-0.12-acceptance.md +35 -0
  12. package/lib/action-reservations.mjs +196 -0
  13. package/lib/core.mjs +219 -2
  14. package/lib/durable-journal.mjs +90 -0
  15. package/lib/operator-dashboard.mjs +198 -0
  16. package/lib/runtime-adapter-v1.mjs +36 -0
  17. package/lib/todo-control-plane.mjs +287 -0
  18. package/lib/upgrade-planner.mjs +24 -0
  19. package/package.json +4 -2
  20. package/scripts/action-reservation-self-test.mjs +65 -0
  21. package/scripts/async-acceptance-refresh-self-test.mjs +46 -0
  22. package/scripts/durable-journal-self-test.mjs +24 -0
  23. package/scripts/human-gate-lifecycle-v2-self-test.mjs +81 -0
  24. package/scripts/live-runtime-soak.mjs +86 -0
  25. package/scripts/operator-dashboard-self-test.mjs +74 -0
  26. package/scripts/production-acceptance.mjs +8 -0
  27. package/scripts/production-soak.mjs +19 -0
  28. package/scripts/route-notify-self-test.mjs +1 -0
  29. package/scripts/runtime-adapter-contract-self-test.mjs +14 -0
  30. package/scripts/todo-control-plane-self-test.mjs +74 -0
  31. package/scripts/upgrade-planner-self-test.mjs +9 -0
  32. package/templates/operator-projection.schema.json +1 -0
  33. 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|立刻执行|立即执行|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);
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,
@@ -1807,6 +1956,56 @@ export async function notifyTerminalTasks(root, options = {}) {
1807
1956
  };
1808
1957
  }
1809
1958
 
1959
+ export async function refreshTaskAcceptance(root, options = {}) {
1960
+ const queue = normalizeLoopId(options.queue);
1961
+ if (!options.taskId) throw new Error('queue-acceptance-refresh requires --task-id.');
1962
+ await ensureQueueDirs(root, queue);
1963
+ const located = await findTaskFile(root, queue, options.taskId);
1964
+ if (!located) throw new Error(`Queue task not found: ${options.taskId}`);
1965
+ const task = await readJson(located.file);
1966
+ const dir = taskRuntimeDirFor(root, queue, task.id);
1967
+ const checkpointsDir = path.join(dir, 'checkpoints');
1968
+ const checkpointFiles = await listJson(checkpointsDir);
1969
+ if (checkpointFiles.length === 0) return { queue, taskId: task.id, outcome: 'no_checkpoints' };
1970
+ const judgementFile = path.join(dir, 'final_judgement.json');
1971
+ const newestCheckpointMs = Math.max(...await Promise.all(checkpointFiles.map(async (file) => (await stat(path.join(checkpointsDir, file))).mtimeMs)));
1972
+ if (await exists(judgementFile) && (await stat(judgementFile)).mtimeMs >= newestCheckpointMs) {
1973
+ return { queue, taskId: task.id, outcome: 'already_current', judgement: path.relative(root, judgementFile) };
1974
+ }
1975
+
1976
+ const contractFile = path.join(dir, 'task_contract.json');
1977
+ const acceptanceFile = path.join(dir, 'acceptance_plan.json');
1978
+ const devFile = path.join(dir, 'dev_plan.json');
1979
+ const taskContract = { contract: await readJson(contractFile), file: path.relative(root, contractFile) };
1980
+ const acceptancePlan = { plan: await readJson(acceptanceFile), file: path.relative(root, acceptanceFile) };
1981
+ const devPlan = {
1982
+ plan: await readJson(devFile),
1983
+ file: path.relative(root, devFile),
1984
+ checkpointsDir: path.relative(root, checkpointsDir),
1985
+ reviewsDir: path.relative(root, path.join(dir, 'reviews'))
1986
+ };
1987
+ const checkpoints = await checkpointSummary(root, devPlan);
1988
+ const acceptanceReviews = await writeAcceptanceReviews(root, queue, task, taskContract, acceptancePlan, devPlan);
1989
+ const finalJudgement = await writeFinalJudgement(root, queue, task, taskContract, acceptancePlan, devPlan, checkpoints, acceptanceReviews, {
1990
+ dispatchStatus: 'completed'
1991
+ });
1992
+ const status = queueStatusFromFinalJudgement('completed', finalJudgement);
1993
+ const destinationName = status === 'completed' ? 'done' : status === 'project_in_progress' ? 'inbox' : 'failed';
1994
+ const destination = path.join(queueSubdirFor(root, queue, destinationName), path.basename(located.file));
1995
+ await writeJson(destination, { ...task, status: status === 'project_in_progress' ? 'queued' : status, acceptanceRefreshedAt: new Date().toISOString() });
1996
+ if (destination !== located.file) await rm(located.file, { force: true });
1997
+ return {
1998
+ queue,
1999
+ taskId: task.id,
2000
+ outcome: 'refreshed',
2001
+ status,
2002
+ checkpointCount: checkpoints.count,
2003
+ accepted: acceptanceReviews.accepted,
2004
+ judgement: finalJudgement.file,
2005
+ task: path.relative(root, destination)
2006
+ };
2007
+ }
2008
+
1810
2009
  function humanInputMessage(queue, task, checkpoint, gateId, language = 'en') {
1811
2010
  const blockers = Array.isArray(checkpoint.blockers) ? checkpoint.blockers : [];
1812
2011
  const blockerText = blockers.length
@@ -3241,11 +3440,29 @@ export async function queueStatus(root, queue) {
3241
3440
  const activeFiles = await listJson(queueSubdirFor(root, queue, 'active'));
3242
3441
  const lock = await readQueueLock(root, queue);
3243
3442
  const lockOwnerAlive = queueLockOwnerAlive(lock);
3443
+ const waitingTasks = [];
3444
+ for (const file of await listJson(queueSubdirFor(root, queue, 'waiting'))) {
3445
+ const task = await readJson(path.join(queueSubdirFor(root, queue, 'waiting'), file));
3446
+ waitingTasks.push({
3447
+ taskId: task.id,
3448
+ status: task.status,
3449
+ waitId: task.parked?.wait_id ?? task.waitingGateId ?? null,
3450
+ waitKind: task.parked?.kind ?? (task.status === 'waiting_for_human' ? 'human_input' : null),
3451
+ displayState: task.parked ? parkedDisplayState(task.parked) : task.status,
3452
+ parkedAt: task.parked?.parked_at ?? task.waitingSince ?? null,
3453
+ reminderCount: Number(task.parked?.reminder_count ?? 0),
3454
+ escalationCount: Number(task.parked?.escalation_count ?? 0),
3455
+ authorizationState: task.parked?.authorization?.state ?? null,
3456
+ actionExecuted: Boolean(task.parked?.execution_boundary?.action_executed)
3457
+ });
3458
+ }
3244
3459
  return {
3245
3460
  queue,
3246
3461
  queued: (await listJson(queueSubdirFor(root, queue, 'inbox'))).length,
3247
3462
  active: activeFiles.length,
3248
- waiting: (await listJson(queueSubdirFor(root, queue, 'waiting'))).length,
3463
+ waiting: waitingTasks.length,
3464
+ waitingStates: waitingTasks.reduce((counts, task) => ({ ...counts, [task.displayState]: (counts[task.displayState] ?? 0) + 1 }), {}),
3465
+ waitingTasks,
3249
3466
  done: (await listJson(queueSubdirFor(root, queue, 'done'))).length,
3250
3467
  failed: (await listJson(queueSubdirFor(root, queue, 'failed'))).length,
3251
3468
  canceled: (await listJson(queueSubdirFor(root, queue, 'canceled'))).length,
@@ -0,0 +1,90 @@
1
+ import { appendFile, copyFile, mkdir, open, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import path from 'node:path';
4
+
5
+ const digest = (value) => createHash('sha256').update(value).digest('hex');
6
+ const canonical = (value) => JSON.stringify(sortValue(value));
7
+ function sortValue(value) {
8
+ if (Array.isArray(value)) return value.map(sortValue);
9
+ if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortValue(value[key])]));
10
+ return value;
11
+ }
12
+
13
+ export class DurableJournal {
14
+ constructor(directory) {
15
+ this.directory = directory;
16
+ this.logFile = path.join(directory, 'events.jsonl');
17
+ this.snapshotFile = path.join(directory, 'snapshot.json');
18
+ }
19
+
20
+ async append(type, payload, transactionId = randomUUID()) {
21
+ await mkdir(this.directory, { recursive: true });
22
+ const previous = (await this.replay()).lastChecksum ?? null;
23
+ const event = { version: 1, transactionId, type, payload, previous };
24
+ event.checksum = digest(canonical(event));
25
+ const handle = await open(this.logFile, 'a');
26
+ try { await handle.write(`${JSON.stringify(event)}\n`); await handle.sync(); } finally { await handle.close(); }
27
+ return event;
28
+ }
29
+
30
+ async replay(reducer = (state, event) => ({ ...state, [event.type]: event.payload }), initial = {}) {
31
+ let raw = '';
32
+ try { raw = await readFile(this.logFile, 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
33
+ let state = initial; let lastChecksum = null; let count = 0;
34
+ const lines = raw.split('\n');
35
+ for (let index = 0; index < lines.length; index++) {
36
+ const line = lines[index];
37
+ if (!line) continue;
38
+ let event;
39
+ try { event = JSON.parse(line); } catch (error) {
40
+ if (index === lines.length - 1) break;
41
+ throw new Error(`journal corruption at line ${index + 1}: ${error.message}`);
42
+ }
43
+ const checksum = event.checksum; const unsigned = { ...event }; delete unsigned.checksum;
44
+ if (digest(canonical(unsigned)) !== checksum || event.previous !== lastChecksum) throw new Error(`journal checksum chain invalid at line ${index + 1}`);
45
+ state = reducer(state, event); lastChecksum = checksum; count++;
46
+ }
47
+ return { state, count, lastChecksum };
48
+ }
49
+
50
+ async checkpoint(state) {
51
+ await mkdir(this.directory, { recursive: true });
52
+ const replay = await this.replay();
53
+ const snapshot = { version: 1, eventCount: replay.count, lastChecksum: replay.lastChecksum, state };
54
+ const temporary = `${this.snapshotFile}.${process.pid}.tmp`;
55
+ await writeFile(temporary, `${JSON.stringify(snapshot, null, 2)}\n`);
56
+ const handle = await open(temporary, 'r'); try { await handle.sync(); } finally { await handle.close(); }
57
+ await rename(temporary, this.snapshotFile);
58
+ return snapshot;
59
+ }
60
+
61
+ async backup(destination) {
62
+ await mkdir(destination, { recursive: true });
63
+ for (const name of ['events.jsonl', 'snapshot.json']) {
64
+ try { await copyFile(path.join(this.directory, name), path.join(destination, name)); } catch (error) { if (error.code !== 'ENOENT') throw error; }
65
+ }
66
+ }
67
+
68
+ static async restore(backup, destination) {
69
+ await mkdir(destination, { recursive: true });
70
+ for (const name of ['events.jsonl', 'snapshot.json']) {
71
+ try { await copyFile(path.join(backup, name), path.join(destination, name)); } catch (error) { if (error.code !== 'ENOENT') throw error; }
72
+ }
73
+ return new DurableJournal(destination).replay();
74
+ }
75
+
76
+ static async migrateV1(stateFile, directory) {
77
+ const journal = new DurableJournal(directory);
78
+ if ((await journal.replay()).count) return journal;
79
+ const state = JSON.parse(await readFile(stateFile, 'utf8'));
80
+ await journal.append('legacy_state_imported', { sourceVersion: state.version, state }, 'migration-v1');
81
+ await journal.checkpoint(state); return journal;
82
+ }
83
+ }
84
+
85
+ export function externalEffectBoundary({ status, idempotencyKey, upstreamEvidence }) {
86
+ if (!idempotencyKey) throw new Error('external side effect requires idempotencyKey');
87
+ if (status === 'accepted' && !upstreamEvidence) throw new Error('accepted side effect requires upstreamEvidence');
88
+ if (!['reserved', 'in_flight', 'unknown', 'accepted', 'not_accepted'].includes(status)) throw new Error('invalid side effect status');
89
+ return { status, idempotencyKey, upstreamEvidence: upstreamEvidence ?? null, replayable: ['reserved', 'not_accepted'].includes(status) };
90
+ }