taskforce-loop-engineering 0.14.0 → 0.15.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
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.15.0 - 2026-08-20
|
|
6
|
+
|
|
7
|
+
- Reconcile project-scoped human gates against the authoritative project contract and acceptance ledger, superseding stale or optional deferred gates without replay.
|
|
8
|
+
- Strengthen doctor and project status checks for structured gate context, acceptance-ledger drift, execution-ledger reconciliation, and project completion.
|
|
9
|
+
- Make active-task supersession project-aware across active and waiting tasks, and add end-to-end regression coverage for durable gate materialization.
|
|
10
|
+
|
|
5
11
|
## 0.14.0 - 2026-08-14
|
|
6
12
|
|
|
7
13
|
- Add the platform-neutral runtime adapter SDK v1 with OpenClaw, Hermes, Codex CLI, and Claude Code factories, shared conformance tests, fail-closed effects, redacted telemetry, migration guidance, and a credential-free demo.
|
package/lib/core.mjs
CHANGED
|
@@ -651,6 +651,7 @@ export async function recentRuns(root, id, options = {}) {
|
|
|
651
651
|
}
|
|
652
652
|
|
|
653
653
|
export async function summarizeLoopRuns(root, options = {}) {
|
|
654
|
+
await reconcileProjectGates(root, options.queue ? { queue: options.queue } : {});
|
|
654
655
|
const ids = await targetRuntimeIds(root, options);
|
|
655
656
|
const summaries = [];
|
|
656
657
|
for (const id of ids) {
|
|
@@ -749,6 +750,24 @@ export async function doctorReport(root, options = {}) {
|
|
|
749
750
|
add('configs-dir', 'warn', await exists(configsDir), path.relative(root, configsDir));
|
|
750
751
|
const runtimeDir = path.join(root, 'runtime', 'loops');
|
|
751
752
|
add('runtime-dir', 'warn', await exists(runtimeDir), path.relative(root, runtimeDir));
|
|
753
|
+
const gateSpecs = new Map((await projectSpecs(root)).map((spec) => [spec.project, spec]));
|
|
754
|
+
const gateQueues = [...new Set([...gateSpecs.values()].flatMap((spec) => (spec.queues ?? []).map((item) => item.queue)))];
|
|
755
|
+
for (const queue of gateQueues) {
|
|
756
|
+
const gatesDir = path.join(queueDirFor(root, queue), 'human-input', 'gates');
|
|
757
|
+
for (const file of await listJson(gatesDir)) {
|
|
758
|
+
const gate = await readJson(path.join(gatesDir, file));
|
|
759
|
+
if (gate.status !== 'waiting_for_human') continue;
|
|
760
|
+
const invalid = !gate.project_id || !gate.contract_hash || !Array.isArray(gate.requirement_ids)
|
|
761
|
+
? { code: 'missing_structured_project_context' }
|
|
762
|
+
: gateInvalidity(gate, gateSpecs.get(gate.project_id));
|
|
763
|
+
add(`human-gate:${gate.gate_id}`, 'fail', !invalid, invalid ?? {
|
|
764
|
+
project_id: gate.project_id,
|
|
765
|
+
contract_version: gate.contract_version,
|
|
766
|
+
milestone_id: gate.milestone_id,
|
|
767
|
+
requirement_ids: gate.requirement_ids
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
}
|
|
752
771
|
try {
|
|
753
772
|
const steps = await listSteps(root);
|
|
754
773
|
const invalid = steps.filter((step) => step.version !== 2 || !step.step_id || !step.input_fingerprint || !['llm', 'tool', 'effect'].includes(step.kind));
|
|
@@ -1025,6 +1044,7 @@ function parkedDisplayState(parked, now = Date.now()) {
|
|
|
1025
1044
|
|
|
1026
1045
|
export async function tickParkedTasks(root, options = {}) {
|
|
1027
1046
|
const queue = normalizeLoopId(options.queue);
|
|
1047
|
+
await reconcileProjectGates(root, { queue });
|
|
1028
1048
|
const now = options.now ? new Date(options.now) : new Date();
|
|
1029
1049
|
if (!Number.isFinite(now.getTime())) throw new Error(`Invalid tick time: ${options.now}`);
|
|
1030
1050
|
if (!options.notifyCommand && !options.dryRun) throw new Error('queue-wait-tick requires --notify-command unless --dry-run is used.');
|
|
@@ -1133,6 +1153,138 @@ function projectConfigPathFor(root, project) {
|
|
|
1133
1153
|
return path.join(root, 'configs', 'loops', 'projects', `${normalizeProjectId(project)}.json`);
|
|
1134
1154
|
}
|
|
1135
1155
|
|
|
1156
|
+
function projectContractVersion(spec) {
|
|
1157
|
+
return spec.contractVersion ?? spec.contract_version ?? spec.schemaVersion ?? null;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function projectContractHash(spec) {
|
|
1161
|
+
return createHash('sha256').update(JSON.stringify(spec)).digest('hex');
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
async function projectSpecs(root) {
|
|
1165
|
+
const dir = path.join(root, 'configs', 'loops', 'projects');
|
|
1166
|
+
const specs = [];
|
|
1167
|
+
for (const file of await listJson(dir)) {
|
|
1168
|
+
try {
|
|
1169
|
+
const spec = await readJson(path.join(dir, file));
|
|
1170
|
+
if (spec?.project) specs.push(spec);
|
|
1171
|
+
} catch { /* doctor reports malformed project files through its normal config checks */ }
|
|
1172
|
+
}
|
|
1173
|
+
return specs;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
async function projectTerminalAccepted(root, spec) {
|
|
1177
|
+
const configuredAcceptanceLedger = spec?.acceptanceLedger ?? spec?.ledger;
|
|
1178
|
+
if (!configuredAcceptanceLedger) return false;
|
|
1179
|
+
try {
|
|
1180
|
+
const ledgerFile = path.resolve(root, safeRelativePath(configuredAcceptanceLedger, 'project acceptance ledger'));
|
|
1181
|
+
const ledger = await readJson(ledgerFile);
|
|
1182
|
+
return ledger.status === 'accepted'
|
|
1183
|
+
&& Array.isArray(ledger.unmet) && ledger.unmet.length === 0
|
|
1184
|
+
&& Array.isArray(ledger.blockers) && ledger.blockers.length === 0;
|
|
1185
|
+
} catch {
|
|
1186
|
+
return false;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function requirementIdsFromCheckpoint(checkpoint, specs) {
|
|
1191
|
+
const explicit = checkpoint?.requirement_ids ?? checkpoint?.requirementIds;
|
|
1192
|
+
if (Array.isArray(explicit)) return [...new Set(explicit.map(String))];
|
|
1193
|
+
const text = JSON.stringify({
|
|
1194
|
+
milestone_id: checkpoint?.milestone_id,
|
|
1195
|
+
blockers: checkpoint?.blockers,
|
|
1196
|
+
next_action: checkpoint?.next_action
|
|
1197
|
+
});
|
|
1198
|
+
const known = new Set(specs.flatMap((spec) => (spec.backlog ?? []).map((item) => String(item.id))));
|
|
1199
|
+
return [...known].filter((id) => new RegExp(`(^|[^A-Za-z0-9_-])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^A-Za-z0-9_-]|$)`).test(text));
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function inferProjectSpec(task, checkpoint, specs, requirementIds = []) {
|
|
1203
|
+
const explicit = checkpoint?.project_id ?? checkpoint?.projectId ?? task?.project_id ?? task?.projectId;
|
|
1204
|
+
if (explicit) return specs.find((spec) => spec.project === explicit) ?? null;
|
|
1205
|
+
const text = `${task?.title ?? ''}\n${task?.body ?? ''}`.toLowerCase();
|
|
1206
|
+
const candidates = specs.map((spec) => ({
|
|
1207
|
+
spec,
|
|
1208
|
+
requirementMatches: requirementIds.filter((id) => (spec.backlog ?? []).some((item) => item.id === id)).length,
|
|
1209
|
+
nameMatch: text.includes(String(spec.project).toLowerCase()) ? 1 : 0
|
|
1210
|
+
})).filter((item) => item.requirementMatches > 0 || item.nameMatch > 0)
|
|
1211
|
+
.sort((a, b) => b.requirementMatches - a.requirementMatches || b.nameMatch - a.nameMatch || b.spec.project.length - a.spec.project.length);
|
|
1212
|
+
return candidates[0]?.spec ?? null;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
async function gateProjectMetadata(root, task, checkpoint) {
|
|
1216
|
+
const specs = await projectSpecs(root);
|
|
1217
|
+
const requirementIds = requirementIdsFromCheckpoint(checkpoint, specs);
|
|
1218
|
+
const spec = inferProjectSpec(task, checkpoint, specs, requirementIds);
|
|
1219
|
+
return {
|
|
1220
|
+
project_id: spec?.project ?? null,
|
|
1221
|
+
contract_version: spec ? projectContractVersion(spec) : null,
|
|
1222
|
+
contract_hash: spec ? projectContractHash(spec) : null,
|
|
1223
|
+
milestone_id: checkpoint?.milestone_id ?? checkpoint?.checkpoint_id ?? null,
|
|
1224
|
+
requirement_ids: requirementIds
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function gateInvalidity(gate, spec) {
|
|
1229
|
+
if (!spec) return { code: 'project_not_found', status: 'superseded' };
|
|
1230
|
+
const backlog = new Map((spec.backlog ?? []).map((item) => [String(item.id), item]));
|
|
1231
|
+
for (const id of gate.requirement_ids ?? []) {
|
|
1232
|
+
const requirement = backlog.get(String(id));
|
|
1233
|
+
if (!requirement) return { code: 'requirement_not_found', requirement_id: id, status: 'superseded' };
|
|
1234
|
+
if (requirement.required === false || ['out_of_scope', 'canceled_by_owner'].includes(requirement.status) || requirement.disposition === 'canceled_by_owner') {
|
|
1235
|
+
return {
|
|
1236
|
+
code: requirement.disposition === 'canceled_by_owner' ? 'canceled_by_owner' : 'requirement_not_required',
|
|
1237
|
+
requirement_id: id,
|
|
1238
|
+
status: requirement.disposition === 'canceled_by_owner' ? 'canceled' : 'superseded'
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
if (gate.contract_version !== projectContractVersion(spec)) {
|
|
1243
|
+
return { code: 'stale_contract', status: 'superseded' };
|
|
1244
|
+
}
|
|
1245
|
+
return null;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
export async function reconcileProjectGates(root, options = {}) {
|
|
1249
|
+
const queues = options.queue
|
|
1250
|
+
? [normalizeLoopId(options.queue)]
|
|
1251
|
+
: [...new Set((await projectSpecs(root)).flatMap((spec) => (spec.queues ?? []).map((item) => item.queue)))];
|
|
1252
|
+
const specs = new Map((await projectSpecs(root)).map((spec) => [spec.project, spec]));
|
|
1253
|
+
const results = [];
|
|
1254
|
+
for (const queue of queues) {
|
|
1255
|
+
await ensureQueueDirs(root, queue);
|
|
1256
|
+
const gatesDir = path.join(queueDirFor(root, queue), 'human-input', 'gates');
|
|
1257
|
+
for (const file of await listJson(gatesDir)) {
|
|
1258
|
+
const full = path.join(gatesDir, file);
|
|
1259
|
+
let gate = await readJson(full);
|
|
1260
|
+
if (gate.status !== 'waiting_for_human') continue;
|
|
1261
|
+
const found = await findTaskFile(root, queue, gate.task_id);
|
|
1262
|
+
const task = found ? await readJson(found.file) : null;
|
|
1263
|
+
const checkpointFile = path.join(taskRuntimeDirFor(root, queue, gate.task_id), 'checkpoints', `${gate.checkpoint_id}.json`);
|
|
1264
|
+
const checkpoint = await exists(checkpointFile) ? await readJson(checkpointFile) : null;
|
|
1265
|
+
if (!gate.project_id || !Array.isArray(gate.requirement_ids) || !gate.contract_hash) {
|
|
1266
|
+
gate = { ...gate, ...await gateProjectMetadata(root, task, checkpoint) };
|
|
1267
|
+
await writeJson(full, gate);
|
|
1268
|
+
}
|
|
1269
|
+
const spec = specs.get(gate.project_id);
|
|
1270
|
+
const invalid = gate.gate_kind === 'deferred_authorization' && await projectTerminalAccepted(root, spec)
|
|
1271
|
+
? { code: 'project_accepted_optional_deferred', status: 'superseded' }
|
|
1272
|
+
: gateInvalidity(gate, spec);
|
|
1273
|
+
if (!invalid) continue;
|
|
1274
|
+
const now = new Date().toISOString();
|
|
1275
|
+
const reconciled = { ...gate, status: invalid.status, reconciliation: { ...invalid, reconciled_at: now } };
|
|
1276
|
+
await writeJson(full, reconciled);
|
|
1277
|
+
if (found?.subdir === 'waiting' && task?.waitingGateId === gate.gate_id) {
|
|
1278
|
+
const canceledFile = path.join(queueSubdirFor(root, queue, 'canceled'), path.basename(found.file));
|
|
1279
|
+
await writeJson(canceledFile, { ...task, status: invalid.status, canceledAt: now, gateReconciliation: reconciled.reconciliation });
|
|
1280
|
+
await rm(found.file, { force: true });
|
|
1281
|
+
}
|
|
1282
|
+
results.push({ queue, gateId: gate.gate_id, projectId: gate.project_id, outcome: invalid.status, reason: invalid.code });
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
return { inspectedQueues: queues.length, reconciled: results.length, results };
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1136
1288
|
function projectLatestIntakePath(root, project) {
|
|
1137
1289
|
return path.join(projectRuntimeDirFor(root, project), 'intake', 'latest.json');
|
|
1138
1290
|
}
|
|
@@ -1594,13 +1746,19 @@ export async function projectStatus(root, options = {}) {
|
|
|
1594
1746
|
} catch {
|
|
1595
1747
|
latestIntake = null;
|
|
1596
1748
|
}
|
|
1597
|
-
const
|
|
1749
|
+
const configuredBacklogSource = spec.backlogSource ?? spec.authoritativeBacklog;
|
|
1750
|
+
const configuredAcceptanceLedger = spec.acceptanceLedger ?? spec.ledger;
|
|
1751
|
+
const backlogFile = configuredBacklogSource
|
|
1752
|
+
? path.resolve(root, safeRelativePath(configuredBacklogSource, 'project backlog source'))
|
|
1753
|
+
: projectInitialBacklogPath(root, project);
|
|
1598
1754
|
let backlog = null;
|
|
1599
1755
|
try {
|
|
1600
1756
|
const loaded = await readJson(backlogFile);
|
|
1601
1757
|
backlog = {
|
|
1602
1758
|
file: path.relative(root, backlogFile),
|
|
1603
|
-
count: Array.isArray(loaded.tasks) ? loaded.tasks.length : 0
|
|
1759
|
+
count: Array.isArray(loaded.tasks) ? loaded.tasks.length : Array.isArray(loaded.items) ? loaded.items.length : 0,
|
|
1760
|
+
status: loaded.status ?? null,
|
|
1761
|
+
updatedAt: loaded.updatedAt ?? loaded.generatedAt ?? null
|
|
1604
1762
|
};
|
|
1605
1763
|
} catch {
|
|
1606
1764
|
backlog = null;
|
|
@@ -1612,11 +1770,45 @@ export async function projectStatus(root, options = {}) {
|
|
|
1612
1770
|
if (queue.status.locked) acc.locked += 1;
|
|
1613
1771
|
return acc;
|
|
1614
1772
|
}, { queued: 0, active: 0, waiting: 0, done: 0, failed: 0, canceled: 0, runs: 0, locked: 0 });
|
|
1773
|
+
let acceptanceLedger = null;
|
|
1774
|
+
if (configuredAcceptanceLedger) {
|
|
1775
|
+
const ledgerFile = path.resolve(root, safeRelativePath(configuredAcceptanceLedger, 'project acceptance ledger'));
|
|
1776
|
+
try {
|
|
1777
|
+
const ledger = await readJson(ledgerFile);
|
|
1778
|
+
acceptanceLedger = {
|
|
1779
|
+
file: path.relative(root, ledgerFile),
|
|
1780
|
+
status: ledger.status ?? null,
|
|
1781
|
+
updatedAt: ledger.updatedAt ?? null,
|
|
1782
|
+
unmet: Array.isArray(ledger.unmet) ? ledger.unmet : [],
|
|
1783
|
+
blockers: Array.isArray(ledger.blockers) ? ledger.blockers : []
|
|
1784
|
+
};
|
|
1785
|
+
} catch (error) {
|
|
1786
|
+
acceptanceLedger = { file: path.relative(root, ledgerFile), readable: false, error: error.message };
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
const registryItems = Array.isArray(spec.backlog) ? spec.backlog : [];
|
|
1790
|
+
const sourceItems = backlog ? await readJson(backlogFile).then((loaded) => loaded.items ?? loaded.tasks ?? []) : [];
|
|
1791
|
+
const registryById = new Map(registryItems.map((item) => [item.id, item]));
|
|
1792
|
+
const sourceById = new Map(sourceItems.map((item) => [item.id, item]));
|
|
1793
|
+
const drift = [];
|
|
1794
|
+
for (const id of new Set([...registryById.keys(), ...sourceById.keys()])) {
|
|
1795
|
+
const registry = registryById.get(id);
|
|
1796
|
+
const authoritative = sourceById.get(id);
|
|
1797
|
+
if (!registry || !authoritative) drift.push({ id, kind: registry ? 'missing_from_authoritative_backlog' : 'missing_from_registry' });
|
|
1798
|
+
else if (registry.status !== authoritative.status || Boolean(registry.required) !== Boolean(authoritative.required)) {
|
|
1799
|
+
drift.push({ id, kind: 'status_or_scope_mismatch', registry: { status: registry.status, required: registry.required }, authoritative: { status: authoritative.status, required: authoritative.required } });
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
const projectCompletion = acceptanceLedger?.status === 'accepted' && acceptanceLedger.unmet.length === 0 && acceptanceLedger.blockers.length === 0
|
|
1803
|
+
? 'accepted'
|
|
1804
|
+
: 'in_progress';
|
|
1615
1805
|
const needsAttention = [];
|
|
1616
1806
|
if (totals.failed > 0) needsAttention.push('failed_tasks_present');
|
|
1617
1807
|
if (totals.waiting > 0) needsAttention.push('human_input_waiting');
|
|
1618
1808
|
if (totals.active > 0) needsAttention.push('active_tasks_present');
|
|
1619
1809
|
if (queues.some((queue) => queue.status.locked)) needsAttention.push('queue_locked');
|
|
1810
|
+
if (drift.length > 0) needsAttention.push('authoritative_source_drift');
|
|
1811
|
+
if (acceptanceLedger?.readable === false) needsAttention.push('acceptance_ledger_unreadable');
|
|
1620
1812
|
return {
|
|
1621
1813
|
version: 1,
|
|
1622
1814
|
project,
|
|
@@ -1627,11 +1819,22 @@ export async function projectStatus(root, options = {}) {
|
|
|
1627
1819
|
queues,
|
|
1628
1820
|
totals,
|
|
1629
1821
|
backlog,
|
|
1822
|
+
acceptanceLedger,
|
|
1823
|
+
authority: {
|
|
1824
|
+
terminalContract: spec.terminalContract ?? null,
|
|
1825
|
+
backlog: backlog?.file ?? null,
|
|
1826
|
+
acceptanceLedger: acceptanceLedger?.file ?? null,
|
|
1827
|
+
rule: 'acceptance ledger determines project completion; backlog source determines remaining work; registry is a projection validated for drift'
|
|
1828
|
+
},
|
|
1829
|
+
consistency: { ok: drift.length === 0 && acceptanceLedger?.readable !== false, drift },
|
|
1830
|
+
projectCompletion,
|
|
1630
1831
|
latestIntake,
|
|
1631
1832
|
needsAttention,
|
|
1632
1833
|
nextActions: needsAttention.length
|
|
1633
1834
|
? ['Inspect queue-status or code-task-status for the queue needing attention.']
|
|
1634
|
-
:
|
|
1835
|
+
: projectCompletion === 'accepted'
|
|
1836
|
+
? ['Project terminal contract is accepted.']
|
|
1837
|
+
: ['Enqueue or run the next safe authorized item from the authoritative backlog.']
|
|
1635
1838
|
};
|
|
1636
1839
|
}
|
|
1637
1840
|
|
|
@@ -1659,6 +1862,7 @@ export async function enqueueTask(root, options) {
|
|
|
1659
1862
|
status: 'queued',
|
|
1660
1863
|
enqueuedAt: new Date().toISOString(),
|
|
1661
1864
|
...(options.riskAssessment ? { riskAssessment: options.riskAssessment } : {}),
|
|
1865
|
+
...(options.projectId ? { projectId: normalizeProjectId(options.projectId) } : {}),
|
|
1662
1866
|
...(options.supersedesTaskId ? { supersedesTaskId: options.supersedesTaskId } : {}),
|
|
1663
1867
|
...(options.supersedeReason ? { supersedeReason: options.supersedeReason } : {}),
|
|
1664
1868
|
...(taskSourceFromOptions(options) ? { source: taskSourceFromOptions(options) } : {})
|
|
@@ -1757,6 +1961,7 @@ async function appendActiveTaskAmendment(root, queue, activeTask, options = {})
|
|
|
1757
1961
|
}
|
|
1758
1962
|
|
|
1759
1963
|
export async function routeLoopMessage(root, options = {}) {
|
|
1964
|
+
if (options.queue) await reconcileProjectGates(root, { queue: options.queue });
|
|
1760
1965
|
const classification = classifyLoopMessage(options.message);
|
|
1761
1966
|
if (!options.route) return classification;
|
|
1762
1967
|
if (classification.intent === 'status') {
|
|
@@ -1785,10 +1990,20 @@ export async function routeLoopMessage(root, options = {}) {
|
|
|
1785
1990
|
if (options.supersedeActive && options.amendActive) {
|
|
1786
1991
|
throw new Error('--supersede-active and --amend-active are mutually exclusive.');
|
|
1787
1992
|
}
|
|
1788
|
-
const
|
|
1789
|
-
const
|
|
1790
|
-
|
|
1791
|
-
|
|
1993
|
+
const specs = await projectSpecs(root);
|
|
1994
|
+
const routedProject = inferProjectSpec({ title: options.title, body: options.message }, null, specs)?.project ?? null;
|
|
1995
|
+
const activeEntries = [];
|
|
1996
|
+
if (options.supersedeActive || options.amendActive) {
|
|
1997
|
+
for (const subdir of options.supersedeActive ? ['active', 'waiting'] : ['active']) {
|
|
1998
|
+
for (const file of await listJson(queueSubdirFor(root, queue, subdir))) {
|
|
1999
|
+
const task = await readJson(path.join(queueSubdirFor(root, queue, subdir), file));
|
|
2000
|
+
const metadata = await gateProjectMetadata(root, task, null);
|
|
2001
|
+
if (routedProject && metadata.project_id !== routedProject) continue;
|
|
2002
|
+
activeEntries.push({ task, subdir, file });
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
const activeTask = activeEntries[0]?.task ?? null;
|
|
1792
2007
|
if (options.amendActive) {
|
|
1793
2008
|
if (!activeTask) throw new Error('No active loop task exists to amend.');
|
|
1794
2009
|
const amended = await appendActiveTaskAmendment(root, queue, activeTask, options);
|
|
@@ -1804,14 +2019,33 @@ export async function routeLoopMessage(root, options = {}) {
|
|
|
1804
2019
|
};
|
|
1805
2020
|
}
|
|
1806
2021
|
if (activeTask) {
|
|
2022
|
+
const now = new Date().toISOString();
|
|
2023
|
+
for (const entry of activeEntries) {
|
|
2024
|
+
const requestFile = path.join(taskRuntimeDirFor(root, queue, entry.task.id), 'supersede_request.json');
|
|
2025
|
+
await writeJson(requestFile, {
|
|
2026
|
+
version: 1,
|
|
2027
|
+
requestedAt: now,
|
|
2028
|
+
activeTaskId: entry.task.id,
|
|
2029
|
+
projectId: routedProject,
|
|
2030
|
+
reason: String(options.message).trim(),
|
|
2031
|
+
status: 'requested'
|
|
2032
|
+
});
|
|
2033
|
+
if (entry.subdir === 'waiting') {
|
|
2034
|
+
const sourceFile = path.join(queueSubdirFor(root, queue, 'waiting'), entry.file);
|
|
2035
|
+
const canceledFile = path.join(queueSubdirFor(root, queue, 'canceled'), entry.file);
|
|
2036
|
+
await writeJson(canceledFile, { ...entry.task, status: 'superseded', canceledAt: now, supersededByNewerRequest: true });
|
|
2037
|
+
await rm(sourceFile, { force: true });
|
|
2038
|
+
const separator = entry.task.waitingGateId?.lastIndexOf(':') ?? -1;
|
|
2039
|
+
if (separator > 0) {
|
|
2040
|
+
const gateFile = path.join(queueDirFor(root, queue), 'human-input', 'gates', `${safeTaskId(entry.task.id)}.${normalizeLoopId(entry.task.waitingGateId.slice(separator + 1))}.json`);
|
|
2041
|
+
if (await exists(gateFile)) {
|
|
2042
|
+
const gate = await readJson(gateFile);
|
|
2043
|
+
await writeJson(gateFile, { ...gate, status: 'superseded', reconciliation: { code: 'superseded_by_owner', reconciled_at: now } });
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
1807
2048
|
const requestFile = path.join(taskRuntimeDirFor(root, queue, activeTask.id), 'supersede_request.json');
|
|
1808
|
-
await writeJson(requestFile, {
|
|
1809
|
-
version: 1,
|
|
1810
|
-
requestedAt: new Date().toISOString(),
|
|
1811
|
-
activeTaskId: activeTask.id,
|
|
1812
|
-
reason: String(options.message).trim(),
|
|
1813
|
-
status: 'requested'
|
|
1814
|
-
});
|
|
1815
2049
|
for (const file of await listJson(queueSubdirFor(root, queue, 'inbox'))) {
|
|
1816
2050
|
const full = path.join(queueSubdirFor(root, queue, 'inbox'), file);
|
|
1817
2051
|
const queued = await readJson(full);
|
|
@@ -1838,7 +2072,8 @@ export async function routeLoopMessage(root, options = {}) {
|
|
|
1838
2072
|
task,
|
|
1839
2073
|
riskAssessment: 'model_assessed',
|
|
1840
2074
|
supersedesTaskId: activeTask?.id,
|
|
1841
|
-
supersedeReason: activeTask ? String(options.message).trim() : null
|
|
2075
|
+
supersedeReason: activeTask ? String(options.message).trim() : null,
|
|
2076
|
+
projectId: routedProject
|
|
1842
2077
|
});
|
|
1843
2078
|
if (activeTask) {
|
|
1844
2079
|
const requestFile = path.join(taskRuntimeDirFor(root, queue, activeTask.id), 'supersede_request.json');
|
|
@@ -1905,6 +2140,16 @@ export async function notifyTerminalTasks(root, options = {}) {
|
|
|
1905
2140
|
throw new Error('queue-terminal-notify requires --notify-command unless --dry-run is used.');
|
|
1906
2141
|
}
|
|
1907
2142
|
await ensureQueueDirs(root, queue);
|
|
2143
|
+
await reconcileProjectGates(root, { queue });
|
|
2144
|
+
const queueConfigFile = path.join(root, 'configs', 'loops', 'queues', `${queue}.json`);
|
|
2145
|
+
let terminalNotificationArchive = { tasks: {} };
|
|
2146
|
+
if (await exists(queueConfigFile)) {
|
|
2147
|
+
const queueConfig = await readJson(queueConfigFile);
|
|
2148
|
+
if (queueConfig.terminalNotificationArchive) {
|
|
2149
|
+
const archiveFile = path.resolve(root, queueConfig.terminalNotificationArchive);
|
|
2150
|
+
if (await exists(archiveFile)) terminalNotificationArchive = await readJson(archiveFile);
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
1908
2153
|
const notificationDir = path.join(queueDirFor(root, queue), 'notifications');
|
|
1909
2154
|
await mkdir(notificationDir, { recursive: true });
|
|
1910
2155
|
const results = [];
|
|
@@ -1913,6 +2158,17 @@ export async function notifyTerminalTasks(root, options = {}) {
|
|
|
1913
2158
|
for (const file of await listJson(dir)) {
|
|
1914
2159
|
const task = await readJson(path.join(dir, file));
|
|
1915
2160
|
if (!task?.id || !task?.status) continue;
|
|
2161
|
+
const archived = terminalNotificationArchive?.tasks?.[task.id];
|
|
2162
|
+
if (archived) {
|
|
2163
|
+
results.push({
|
|
2164
|
+
taskId: task.id,
|
|
2165
|
+
status: task.status,
|
|
2166
|
+
outcome: 'archived_unroutable',
|
|
2167
|
+
reason: archived.reason,
|
|
2168
|
+
archive: path.relative(root, path.resolve(root, terminalNotificationArchive.archiveFile ?? queueConfigFile))
|
|
2169
|
+
});
|
|
2170
|
+
continue;
|
|
2171
|
+
}
|
|
1916
2172
|
if (!task?.source?.channel || !task?.source?.target) {
|
|
1917
2173
|
results.push({
|
|
1918
2174
|
taskId: task.id,
|
|
@@ -2024,8 +2280,10 @@ export async function refreshTaskAcceptance(root, options = {}) {
|
|
|
2024
2280
|
|
|
2025
2281
|
function humanInputMessage(queue, task, checkpoint, gateId, language = 'en') {
|
|
2026
2282
|
const blockers = Array.isArray(checkpoint.blockers) ? checkpoint.blockers : [];
|
|
2027
|
-
const
|
|
2028
|
-
|
|
2283
|
+
const deferredGates = Array.isArray(checkpoint.deferred_gates) ? checkpoint.deferred_gates : [];
|
|
2284
|
+
const requirements = blockers.length > 0 ? blockers : deferredGates;
|
|
2285
|
+
const blockerText = requirements.length
|
|
2286
|
+
? requirements.map((item) => typeof item === 'string' ? item : item?.required_authority ?? item?.human_action_required ?? item?.user_action ?? item?.reason ?? item?.description ?? item?.message ?? JSON.stringify(item))
|
|
2029
2287
|
: [checkpoint.next_action ?? 'Human input is required before the task can continue.'];
|
|
2030
2288
|
if (language === 'zh') return [
|
|
2031
2289
|
'Loop 任务正在等待你的输入',
|
|
@@ -2056,8 +2314,70 @@ async function tasksById(root, queue) {
|
|
|
2056
2314
|
return tasks;
|
|
2057
2315
|
}
|
|
2058
2316
|
|
|
2317
|
+
function taskRecency(task) {
|
|
2318
|
+
// Queue-carrier authority follows creation/enqueue order. Later bookkeeping
|
|
2319
|
+
// updates on an old task must never make it newer than its successor.
|
|
2320
|
+
const value = task?.enqueuedAt ?? task?.createdAt ?? task?.updatedAt ?? task?.completedAt ?? '';
|
|
2321
|
+
const parsed = Date.parse(value);
|
|
2322
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
async function authoritativeHumanGateCandidates(root, queue, tasks) {
|
|
2326
|
+
const specs = await projectSpecs(root);
|
|
2327
|
+
const candidates = [];
|
|
2328
|
+
for (const [taskId, entry] of tasks) {
|
|
2329
|
+
if (!entry.task.source || entry.subdir === 'canceled') continue;
|
|
2330
|
+
const runtimeDir = taskRuntimeDirFor(root, queue, taskId);
|
|
2331
|
+
let files = await listJson(path.join(runtimeDir, 'checkpoints'));
|
|
2332
|
+
const judgementFile = path.join(runtimeDir, 'final_judgement.json');
|
|
2333
|
+
if (await exists(judgementFile)) {
|
|
2334
|
+
const judgement = await readJson(judgementFile);
|
|
2335
|
+
const effectiveIds = new Set(Array.isArray(judgement?.coverage?.effective_review_ids)
|
|
2336
|
+
? judgement.coverage.effective_review_ids
|
|
2337
|
+
: []);
|
|
2338
|
+
if (effectiveIds.size > 0) files = files.filter((file) => effectiveIds.has(path.basename(file, '.json')));
|
|
2339
|
+
}
|
|
2340
|
+
const checkpoints = [];
|
|
2341
|
+
for (const file of files) {
|
|
2342
|
+
const checkpointFile = path.join(runtimeDir, 'checkpoints', file);
|
|
2343
|
+
const checkpoint = await readJson(checkpointFile);
|
|
2344
|
+
const deferred = Array.isArray(checkpoint?.deferred_gates) ? checkpoint.deferred_gates : [];
|
|
2345
|
+
if (['needs_human_input', 'blocked'].includes(checkpoint?.status) || deferred.length > 0) {
|
|
2346
|
+
checkpoints.push({ file, checkpoint, mtimeMs: (await stat(checkpointFile)).mtimeMs });
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
if (checkpoints.length === 0) continue;
|
|
2350
|
+
checkpoints.sort((a, b) => Number(b.checkpoint.sequence ?? 0) - Number(a.checkpoint.sequence ?? 0)
|
|
2351
|
+
|| b.mtimeMs - a.mtimeMs
|
|
2352
|
+
|| String(b.checkpoint.checkpoint_id ?? b.file).localeCompare(String(a.checkpoint.checkpoint_id ?? a.file)));
|
|
2353
|
+
const metadata = await gateProjectMetadata(root, entry.task, checkpoints[0].checkpoint);
|
|
2354
|
+
const latestCheckpoint = checkpoints[0].checkpoint;
|
|
2355
|
+
const deferredOnly = Array.isArray(latestCheckpoint.deferred_gates)
|
|
2356
|
+
&& latestCheckpoint.deferred_gates.length > 0
|
|
2357
|
+
&& (!Array.isArray(latestCheckpoint.blockers) || latestCheckpoint.blockers.length === 0)
|
|
2358
|
+
&& !['needs_human_input', 'blocked'].includes(latestCheckpoint.status);
|
|
2359
|
+
const spec = specs.find((item) => item.project === metadata.project_id);
|
|
2360
|
+
if (deferredOnly && await projectTerminalAccepted(root, spec)) continue;
|
|
2361
|
+
candidates.push({ taskId, entry, projectId: metadata.project_id, ...checkpoints[0] });
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
// A project deferred gate is materialized only from its newest authoritative
|
|
2365
|
+
// carrier. Historical terminal tasks are audit evidence, not a replay queue.
|
|
2366
|
+
const newestByProject = new Map();
|
|
2367
|
+
for (const candidate of candidates.filter((item) => item.projectId)) {
|
|
2368
|
+
const current = newestByProject.get(candidate.projectId);
|
|
2369
|
+
if (!current || taskRecency(candidate.entry.task) > taskRecency(current.entry.task)) newestByProject.set(candidate.projectId, candidate);
|
|
2370
|
+
}
|
|
2371
|
+
return candidates.filter((candidate) => {
|
|
2372
|
+
if (candidate.projectId) return newestByProject.get(candidate.projectId) === candidate && candidate.entry.subdir !== 'failed';
|
|
2373
|
+
return ['inbox', 'active', 'waiting'].includes(candidate.entry.subdir)
|
|
2374
|
+
|| (candidate.entry.subdir === 'failed' && ['needs_human_input', 'blocked'].includes(candidate.checkpoint.status));
|
|
2375
|
+
});
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2059
2378
|
export async function notifyHumanInputRequests(root, options = {}) {
|
|
2060
2379
|
const queue = normalizeLoopId(options.queue);
|
|
2380
|
+
await reconcileProjectGates(root, { queue });
|
|
2061
2381
|
const language = await installedQueueLanguage(root, queue, options.language);
|
|
2062
2382
|
if (!options.notifyCommand && !options.dryRun) {
|
|
2063
2383
|
throw new Error('queue-human-input-notify requires --notify-command unless --dry-run is used.');
|
|
@@ -2067,29 +2387,15 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
2067
2387
|
const gatesDir = path.join(queueDirFor(root, queue), 'human-input', 'gates');
|
|
2068
2388
|
await mkdir(gatesDir, { recursive: true });
|
|
2069
2389
|
const results = [];
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
let checkpointFiles = await listJson(checkpointsDir);
|
|
2074
|
-
const judgementFile = path.join(taskRuntimeDirFor(root, queue, taskId), 'final_judgement.json');
|
|
2075
|
-
if (await exists(judgementFile)) {
|
|
2076
|
-
const judgement = await readJson(judgementFile);
|
|
2077
|
-
const effectiveIds = Array.isArray(judgement?.coverage?.effective_review_ids)
|
|
2078
|
-
? new Set(judgement.coverage.effective_review_ids)
|
|
2079
|
-
: null;
|
|
2080
|
-
if (effectiveIds?.size > 0) {
|
|
2081
|
-
checkpointFiles = checkpointFiles.filter((file) => effectiveIds.has(path.basename(file, '.json')));
|
|
2082
|
-
}
|
|
2083
|
-
}
|
|
2084
|
-
for (const file of checkpointFiles) {
|
|
2085
|
-
const checkpoint = await readJson(path.join(checkpointsDir, file));
|
|
2086
|
-
if (!['needs_human_input', 'blocked'].includes(checkpoint?.status)) continue;
|
|
2390
|
+
const candidates = await authoritativeHumanGateCandidates(root, queue, tasks);
|
|
2391
|
+
for (const { taskId, entry, file, checkpoint } of candidates) {
|
|
2392
|
+
const deferredGates = Array.isArray(checkpoint?.deferred_gates) ? checkpoint.deferred_gates : [];
|
|
2087
2393
|
const checkpointId = checkpoint.checkpoint_id ?? path.basename(file, '.json');
|
|
2088
2394
|
const gateId = `${taskId}:${checkpointId}`;
|
|
2089
2395
|
const ledgerFile = path.join(gatesDir, `${safeTaskId(taskId)}.${normalizeLoopId(checkpointId)}.json`);
|
|
2090
2396
|
if (await exists(ledgerFile)) {
|
|
2091
2397
|
const gate = await readJson(ledgerFile);
|
|
2092
|
-
if (gate.status === 'waiting_for_human' && ['inbox', 'failed'].includes(entry.subdir)) {
|
|
2398
|
+
if (gate.status === 'waiting_for_human' && ['inbox', 'done', 'failed'].includes(entry.subdir)) {
|
|
2093
2399
|
const sourceFile = path.join(queueSubdirFor(root, queue, entry.subdir), `${safeTaskId(taskId)}.json`);
|
|
2094
2400
|
if (await exists(sourceFile)) {
|
|
2095
2401
|
const waitingFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(sourceFile));
|
|
@@ -2097,13 +2403,46 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
2097
2403
|
...entry.task,
|
|
2098
2404
|
status: 'waiting_for_human',
|
|
2099
2405
|
waitingGateId: gateId,
|
|
2406
|
+
waitKind: gate.gate_kind ?? 'human_input',
|
|
2100
2407
|
waitingSince: gate.requested_at ?? new Date().toISOString()
|
|
2101
2408
|
});
|
|
2102
2409
|
await rm(sourceFile, { force: true });
|
|
2103
2410
|
}
|
|
2104
2411
|
}
|
|
2105
|
-
|
|
2106
|
-
|
|
2412
|
+
const notified = gate.notification_record?.status === 'sent' || gate.notification?.exitCode === 0;
|
|
2413
|
+
if (gate.status !== 'waiting_for_human' || notified || options.dryRun) {
|
|
2414
|
+
results.push({ taskId, checkpointId, gateId, outcome: gate.status === 'resolved' ? 'resolved' : 'already_notified', ledger: path.relative(root, ledgerFile) });
|
|
2415
|
+
continue;
|
|
2416
|
+
}
|
|
2417
|
+
// A prior notification failure is safely retryable because the durable
|
|
2418
|
+
// gate/task transition already exists and notification_record is the cursor.
|
|
2419
|
+
} else if (!options.dryRun) {
|
|
2420
|
+
const now = new Date().toISOString();
|
|
2421
|
+
await writeJson(ledgerFile, {
|
|
2422
|
+
version: 1, gate_id: gateId, queue, task_id: taskId, checkpoint_id: checkpointId,
|
|
2423
|
+
...await gateProjectMetadata(root, entry.task, checkpoint), status: 'waiting_for_human',
|
|
2424
|
+
gate_kind: deferredGates.length > 0 ? 'deferred_authorization' : 'human_input',
|
|
2425
|
+
authorization_requirements: deferredGates.map((gate, index) => ({
|
|
2426
|
+
id: gate?.id ?? `deferred-${index + 1}`, action: gate?.action ?? gate?.kind ?? null,
|
|
2427
|
+
required_authority: gate?.required_authority ?? gate?.human_action_required ?? gate?.reason ?? gate?.description ?? String(gate),
|
|
2428
|
+
scope: gate?.scope ?? null
|
|
2429
|
+
})), source: entry.task.source, requested_at: now,
|
|
2430
|
+
notification_record: { status: 'pending', attempts: 0, idempotency_key: gateId }
|
|
2431
|
+
});
|
|
2432
|
+
if (['inbox', 'done', 'failed'].includes(entry.subdir)) {
|
|
2433
|
+
const sourceFile = path.join(queueSubdirFor(root, queue, entry.subdir), `${safeTaskId(taskId)}.json`);
|
|
2434
|
+
if (await exists(sourceFile)) {
|
|
2435
|
+
const waitingFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(sourceFile));
|
|
2436
|
+
await writeJson(waitingFile, {
|
|
2437
|
+
...entry.task,
|
|
2438
|
+
status: 'waiting_for_human',
|
|
2439
|
+
waitingGateId: gateId,
|
|
2440
|
+
waitKind: deferredGates.length > 0 ? 'deferred_authorization' : 'human_input',
|
|
2441
|
+
waitingSince: now
|
|
2442
|
+
});
|
|
2443
|
+
await rm(sourceFile, { force: true });
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2107
2446
|
}
|
|
2108
2447
|
const message = humanInputMessage(queue, entry.task, checkpoint, gateId, language);
|
|
2109
2448
|
if (options.dryRun) {
|
|
@@ -2124,36 +2463,20 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
2124
2463
|
}
|
|
2125
2464
|
});
|
|
2126
2465
|
if (result.exitCode !== 0) {
|
|
2466
|
+
const gate = await readJson(ledgerFile);
|
|
2467
|
+
await writeJson(ledgerFile, { ...gate, request: message, notification_record: {
|
|
2468
|
+
status: 'failed', attempts: Number(gate.notification_record?.attempts ?? 0) + 1,
|
|
2469
|
+
idempotency_key: gateId, last_attempt_at: new Date().toISOString(), result: compactCommandResult(result)
|
|
2470
|
+
} });
|
|
2127
2471
|
results.push({ taskId, checkpointId, gateId, outcome: 'failed', result: compactCommandResult(result) });
|
|
2128
2472
|
continue;
|
|
2129
2473
|
}
|
|
2130
|
-
await
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
checkpoint_id: checkpointId,
|
|
2136
|
-
status: 'waiting_for_human',
|
|
2137
|
-
source: entry.task.source,
|
|
2138
|
-
request: message,
|
|
2139
|
-
requested_at: new Date().toISOString(),
|
|
2140
|
-
notification: compactCommandResult(result)
|
|
2141
|
-
});
|
|
2142
|
-
if (['inbox', 'failed'].includes(entry.subdir)) {
|
|
2143
|
-
const sourceFile = path.join(queueSubdirFor(root, queue, entry.subdir), `${safeTaskId(taskId)}.json`);
|
|
2144
|
-
if (await exists(sourceFile)) {
|
|
2145
|
-
const waitingFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(sourceFile));
|
|
2146
|
-
await writeJson(waitingFile, {
|
|
2147
|
-
...entry.task,
|
|
2148
|
-
status: 'waiting_for_human',
|
|
2149
|
-
waitingGateId: gateId,
|
|
2150
|
-
waitingSince: new Date().toISOString()
|
|
2151
|
-
});
|
|
2152
|
-
await rm(sourceFile, { force: true });
|
|
2153
|
-
}
|
|
2154
|
-
}
|
|
2474
|
+
const gate = await readJson(ledgerFile);
|
|
2475
|
+
await writeJson(ledgerFile, { ...gate, request: message, notification: compactCommandResult(result), notification_record: {
|
|
2476
|
+
status: 'sent', attempts: Number(gate.notification_record?.attempts ?? 0) + 1,
|
|
2477
|
+
idempotency_key: gateId, sent_at: new Date().toISOString()
|
|
2478
|
+
} });
|
|
2155
2479
|
results.push({ taskId, checkpointId, gateId, outcome: 'sent', ledger: path.relative(root, ledgerFile) });
|
|
2156
|
-
}
|
|
2157
2480
|
}
|
|
2158
2481
|
return {
|
|
2159
2482
|
queue,
|
|
@@ -2466,14 +2789,25 @@ function defaultBlockedActions(riskLevel) {
|
|
|
2466
2789
|
return blocked;
|
|
2467
2790
|
}
|
|
2468
2791
|
|
|
2792
|
+
export function inferTaskScope(task) {
|
|
2793
|
+
const requestText = `${task?.title ?? ''}\n${task?.body ?? ''}`.toLowerCase();
|
|
2794
|
+
// A project can be referenced as context while the routed task is explicitly
|
|
2795
|
+
// bounded to one milestone or backlog item. Those bounded instructions must
|
|
2796
|
+
// win; otherwise phrases such as "do not call the overall project complete"
|
|
2797
|
+
// incorrectly turn an accepted milestone into an endlessly requeued project
|
|
2798
|
+
// task.
|
|
2799
|
+
const boundedMilestone = /(?:only|仅|只)(?:推进|处理|执行|完成|验证)|阶段完成|任务完成|第\s*\d+\s*(?:项|步)|选项\s*\d+|boundary item|backlog item|(?:取消|撤销|移除).{0,40}(?:功能|范围|计划|必做|门槛)|(?:cancel|remove|drop|withdraw).{0,40}(?:feature|scope|requirement|backlog item|completion gate)|(?:product[-_ ]scope|产品范围).{0,20}(?:decision|amendment|决策|修订)/.test(requestText);
|
|
2800
|
+
if (boundedMilestone) return 'scoped_task';
|
|
2801
|
+
return /project[-_ ]level|项目级|完整项目|整体项目|single milestone|单(?:一)?里程碑/.test(requestText)
|
|
2802
|
+
? 'project'
|
|
2803
|
+
: 'scoped_task';
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2469
2806
|
function buildTaskContract(queue, task, options = {}) {
|
|
2470
2807
|
const inferredRisk = inferTaskRisk(task);
|
|
2471
2808
|
const modelAssessed = task.riskAssessment === 'model_assessed';
|
|
2472
2809
|
const riskLevel = options.riskLevel ?? (modelAssessed ? 'model_assessed' : inferredRisk.level);
|
|
2473
|
-
const
|
|
2474
|
-
const taskScope = /project[-_ ]level|项目级|完整项目|整体项目|single milestone|单(?:一)?里程碑/.test(requestText)
|
|
2475
|
-
? 'project'
|
|
2476
|
-
: 'scoped_task';
|
|
2810
|
+
const taskScope = inferTaskScope(task);
|
|
2477
2811
|
return {
|
|
2478
2812
|
version: 1,
|
|
2479
2813
|
task_id: task.id,
|
|
@@ -2584,11 +2918,12 @@ export async function writeTaskContract(root, queue, task, options = {}) {
|
|
|
2584
2918
|
const dir = taskRuntimeDirFor(root, queue, task.id);
|
|
2585
2919
|
const file = path.join(dir, 'task_contract.json');
|
|
2586
2920
|
const historicalPatterns = await retrieveHistoricalPatterns(root, queue, task, options.history ?? {});
|
|
2587
|
-
|
|
2921
|
+
let contract = buildTaskContract(queue, task, {
|
|
2588
2922
|
...options,
|
|
2589
2923
|
workspace: options.workspace ?? root,
|
|
2590
2924
|
historicalPatterns
|
|
2591
2925
|
});
|
|
2926
|
+
contract = await mergeLiveAmendments(root, queue, task.id, contract, 'supplemental_requirements');
|
|
2592
2927
|
await writeJson(file, contract);
|
|
2593
2928
|
return {
|
|
2594
2929
|
contract,
|
|
@@ -2596,6 +2931,36 @@ export async function writeTaskContract(root, queue, task, options = {}) {
|
|
|
2596
2931
|
};
|
|
2597
2932
|
}
|
|
2598
2933
|
|
|
2934
|
+
async function mergeLiveAmendments(root, queue, taskId, artifact, supplementalField) {
|
|
2935
|
+
const amendmentsDir = path.join(taskRuntimeDirFor(root, queue, taskId), 'amendments');
|
|
2936
|
+
if (!(await exists(amendmentsDir))) return artifact;
|
|
2937
|
+
const files = (await listJson(amendmentsDir)).filter((file) => /^\d{4}\.json$/.test(file)).sort();
|
|
2938
|
+
if (files.length === 0) return artifact;
|
|
2939
|
+
const refs = [];
|
|
2940
|
+
const instructions = [];
|
|
2941
|
+
for (const name of files) {
|
|
2942
|
+
const amendment = await readJson(path.join(amendmentsDir, name));
|
|
2943
|
+
const instruction = String(amendment.instruction ?? amendment.requirement ?? '').trim();
|
|
2944
|
+
if (!instruction) continue;
|
|
2945
|
+
const sequence = Number(amendment.sequence ?? refs.length + 1);
|
|
2946
|
+
refs.push({
|
|
2947
|
+
sequence,
|
|
2948
|
+
instruction,
|
|
2949
|
+
requested_at: amendment.requestedAt ?? amendment.created_at ?? null,
|
|
2950
|
+
artifact: path.relative(root, path.join(amendmentsDir, name))
|
|
2951
|
+
});
|
|
2952
|
+
instructions.push(instruction);
|
|
2953
|
+
}
|
|
2954
|
+
if (refs.length === 0) return artifact;
|
|
2955
|
+
return {
|
|
2956
|
+
...artifact,
|
|
2957
|
+
amendment_version: Math.max(...refs.map((item) => item.sequence)),
|
|
2958
|
+
amendments: refs,
|
|
2959
|
+
[supplementalField]: instructions,
|
|
2960
|
+
updated_at: new Date().toISOString()
|
|
2961
|
+
};
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2599
2964
|
function inferAcceptanceSignals(contract) {
|
|
2600
2965
|
const text = `${contract.title ?? ''}\n${contract.original_request ?? ''}`.toLowerCase();
|
|
2601
2966
|
return {
|
|
@@ -2776,7 +3141,8 @@ function buildAcceptancePlan(contract, options = {}) {
|
|
|
2776
3141
|
export async function writeAcceptancePlan(root, queue, task, taskContract, options = {}) {
|
|
2777
3142
|
const dir = taskRuntimeDirFor(root, queue, task.id);
|
|
2778
3143
|
const file = path.join(dir, 'acceptance_plan.json');
|
|
2779
|
-
|
|
3144
|
+
let plan = buildAcceptancePlan(taskContract.contract, options);
|
|
3145
|
+
plan = await mergeLiveAmendments(root, queue, task.id, plan, 'supplemental_checks');
|
|
2780
3146
|
await writeJson(file, plan);
|
|
2781
3147
|
return {
|
|
2782
3148
|
plan,
|
|
@@ -2843,7 +3209,8 @@ export async function writeDevPlan(root, queue, task, taskContract, acceptancePl
|
|
|
2843
3209
|
await mkdir(checkpointsDir, { recursive: true });
|
|
2844
3210
|
await mkdir(reviewsDir, { recursive: true });
|
|
2845
3211
|
const file = path.join(dir, 'dev_plan.json');
|
|
2846
|
-
|
|
3212
|
+
let plan = buildDevPlan(taskContract.contract, acceptancePlan.plan);
|
|
3213
|
+
plan = await mergeLiveAmendments(root, queue, task.id, plan, 'supplemental_instructions');
|
|
2847
3214
|
await writeJson(file, plan);
|
|
2848
3215
|
return {
|
|
2849
3216
|
plan,
|
|
@@ -3123,6 +3490,7 @@ export async function writeAcceptanceReviews(root, queue, task, taskContract, ac
|
|
|
3123
3490
|
checkpointCreatedAt: checkpoint.created_at ?? null,
|
|
3124
3491
|
createdAt: checkpoint.created_at ?? review.created_at,
|
|
3125
3492
|
projectCompletion: checkpoint.project_completion ?? null,
|
|
3493
|
+
deferredGates: Array.isArray(checkpoint.deferred_gates) ? checkpoint.deferred_gates : [],
|
|
3126
3494
|
status: review.status,
|
|
3127
3495
|
file: path.relative(root, reviewFile)
|
|
3128
3496
|
});
|
|
@@ -3195,6 +3563,8 @@ export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoin
|
|
|
3195
3563
|
const dispatchStatus = context.dispatchStatus ?? 'unknown';
|
|
3196
3564
|
const dispatchFailure = context.dispatchFailureClassification ?? null;
|
|
3197
3565
|
const projectCompletionAccepted = effectiveReviews.some((review) => review.projectCompletion?.status === 'accepted');
|
|
3566
|
+
const projectCompletionInProgress = effectiveReviews.some((review) => review.projectCompletion?.status === 'in_progress');
|
|
3567
|
+
const deferredGateCount = effectiveReviews.reduce((count, review) => count + (review.deferredGates?.length ?? 0), 0);
|
|
3198
3568
|
let outcome = 'needs_revision';
|
|
3199
3569
|
|
|
3200
3570
|
if (dispatchStatus === 'runtime_interrupted') {
|
|
@@ -3245,10 +3615,11 @@ export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoin
|
|
|
3245
3615
|
outcome = 'needs_revision';
|
|
3246
3616
|
reasons.push(`Accepted checkpoints (${acceptedCount}) do not cover required checkpoints (${Math.max(requiredCheckpoints, 1)}).`);
|
|
3247
3617
|
nextActions.push('Complete and review the remaining planned checkpoints.');
|
|
3248
|
-
} else if (contract.task_scope === 'project' && !projectCompletionAccepted) {
|
|
3618
|
+
} else if ((contract.task_scope === 'project' || projectCompletionInProgress || deferredGateCount > 0) && !projectCompletionAccepted) {
|
|
3249
3619
|
outcome = 'project_in_progress';
|
|
3250
3620
|
reasons.push('The latest milestone is accepted, but the project terminal contract is not accepted.');
|
|
3251
|
-
|
|
3621
|
+
if (deferredGateCount > 0) reasons.push(`${deferredGateCount} deferred authorization gate(s) remain and must be materialized as waiting gates when no unrelated safe work is actionable.`);
|
|
3622
|
+
nextActions.push('Reread the authoritative project ledger and continue with the next safe actionable backlog item, or wait on a structured authorization gate.');
|
|
3252
3623
|
} else if (contract.requires_human_gate) {
|
|
3253
3624
|
outcome = 'ready_for_human_review';
|
|
3254
3625
|
reasons.push('All reviewed checkpoints are accepted, and the task contract requires a human gate.');
|
|
@@ -3285,6 +3656,7 @@ export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoin
|
|
|
3285
3656
|
accepted: acceptedCount,
|
|
3286
3657
|
revise: reviseCount,
|
|
3287
3658
|
blocked: blockedCount,
|
|
3659
|
+
deferred_gates: deferredGateCount,
|
|
3288
3660
|
rubric_items: Array.isArray(acceptancePlan?.rubric) ? acceptancePlan.rubric.length : 0,
|
|
3289
3661
|
automation_suggestions: Array.isArray(acceptancePlan?.automation) ? acceptancePlan.automation.length : 0
|
|
3290
3662
|
},
|
|
@@ -3453,6 +3825,7 @@ async function listJson(dir) {
|
|
|
3453
3825
|
export async function queueStatus(root, queue) {
|
|
3454
3826
|
normalizeLoopId(queue);
|
|
3455
3827
|
await ensureQueueDirs(root, queue);
|
|
3828
|
+
await reconcileProjectGates(root, { queue });
|
|
3456
3829
|
const activeFiles = await listJson(queueSubdirFor(root, queue, 'active'));
|
|
3457
3830
|
const lock = await readQueueLock(root, queue);
|
|
3458
3831
|
const lockOwnerAlive = queueLockOwnerAlive(lock);
|
|
@@ -3463,7 +3836,7 @@ export async function queueStatus(root, queue) {
|
|
|
3463
3836
|
taskId: task.id,
|
|
3464
3837
|
status: task.status,
|
|
3465
3838
|
waitId: task.parked?.wait_id ?? task.waitingGateId ?? null,
|
|
3466
|
-
waitKind: task.parked?.kind ?? (task.status === 'waiting_for_human' ? 'human_input' : null),
|
|
3839
|
+
waitKind: task.parked?.kind ?? task.waitKind ?? (task.status === 'waiting_for_human' ? 'human_input' : null),
|
|
3467
3840
|
displayState: task.parked ? parkedDisplayState(task.parked) : task.status,
|
|
3468
3841
|
parkedAt: task.parked?.parked_at ?? task.waitingSince ?? null,
|
|
3469
3842
|
reminderCount: Number(task.parked?.reminder_count ?? 0),
|
|
@@ -8153,13 +8526,22 @@ function shellQuote(value) {
|
|
|
8153
8526
|
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
8154
8527
|
}
|
|
8155
8528
|
|
|
8529
|
+
// Linux limits the size of a single argv/env string (commonly 128 KiB). Revision
|
|
8530
|
+
// tasks can legitimately exceed that after many checkpoints, so keep the
|
|
8531
|
+
// legacy inline body only for bounded tasks. The authoritative task body is
|
|
8532
|
+
// always available through LOOP_TASK_FILE.
|
|
8533
|
+
const MAX_INLINE_TASK_BODY_BYTES = 32 * 1024;
|
|
8534
|
+
|
|
8156
8535
|
function buildQueueEnv(root, queue, task, taskFile, runId, extra = {}) {
|
|
8536
|
+
const taskBody = String(task.body ?? '');
|
|
8537
|
+
const inlineTaskBody = Buffer.byteLength(taskBody, 'utf8') <= MAX_INLINE_TASK_BODY_BYTES;
|
|
8157
8538
|
return {
|
|
8158
8539
|
...process.env,
|
|
8159
8540
|
LOOP_QUEUE_ID: queue,
|
|
8160
8541
|
LOOP_TASK_ID: task.id,
|
|
8161
8542
|
LOOP_TASK_TITLE: task.title,
|
|
8162
|
-
LOOP_TASK_BODY:
|
|
8543
|
+
...(inlineTaskBody ? { LOOP_TASK_BODY: taskBody } : {}),
|
|
8544
|
+
LOOP_TASK_BODY_MODE: inlineTaskBody ? 'inline' : 'task_file',
|
|
8163
8545
|
LOOP_TASK_FILE: taskFile,
|
|
8164
8546
|
LOOP_TASK_FILE_REL: path.relative(root, taskFile),
|
|
8165
8547
|
LOOP_RUN_ID: runId,
|
|
@@ -8481,6 +8863,7 @@ async function runPreflight(root, config, timeoutMs) {
|
|
|
8481
8863
|
export async function runQueueOnce(root, options) {
|
|
8482
8864
|
const queue = normalizeLoopId(options.queue);
|
|
8483
8865
|
await ensureQueueDirs(root, queue);
|
|
8866
|
+
await reconcileProjectGates(root, { queue });
|
|
8484
8867
|
let progressTask = null;
|
|
8485
8868
|
const liveProgressNotifications = [];
|
|
8486
8869
|
let liveProgressChain = Promise.resolve();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "taskforce-loop-engineering",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "OpenClaw-native loop engineering CLI, templates, and skill for verifiable agent work loops.",
|
|
6
6
|
"type": "module",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"check:config-drift": "node --check scripts/config-drift-self-test.mjs && node scripts/config-drift-self-test.mjs",
|
|
24
24
|
"check:openclaw-install": "node --check scripts/openclaw-install.mjs && node --check scripts/openclaw-doctor.mjs && node --check scripts/openclaw-smoke.mjs && node --check scripts/openclaw-manage.mjs && node scripts/openclaw-install-self-test.mjs",
|
|
25
25
|
"check:hermes-install": "node --check scripts/hermes-install.mjs && node --check scripts/hermes-doctor.mjs && node --check scripts/hermes-smoke.mjs && node scripts/hermes-install-self-test.mjs",
|
|
26
|
+
"check:project-gates": "node --check lib/core.mjs && node scripts/project-gate-reconciliation-self-test.mjs",
|
|
26
27
|
"check": "npm run check:config-drift && npm run check:openclaw-install && npm run check:hermes-install && node --check bin/loop-engineering.mjs && node --check lib/core.mjs && node --check lib/action-reservations.mjs && node --check lib/todo-control-plane.mjs && node --check lib/operator-dashboard.mjs && node scripts/action-reservation-self-test.mjs && node scripts/todo-control-plane-self-test.mjs && node scripts/operator-dashboard-self-test.mjs && node scripts/final-judgement-self-test.mjs && node scripts/route-notify-self-test.mjs && node scripts/scheduler-heartbeat-self-test.mjs && node scripts/human-gate-lifecycle-v2-self-test.mjs && node bin/loop-engineering.mjs verify --config templates/workspace-health.json --root . && node bin/loop-engineering.mjs queue-status --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs project-intake --root /tmp/loop-engineering-check --name smoke-project --brief \"Build a small website project\" --type auto --check \"npm test\" --json >/dev/null && node bin/loop-engineering.mjs project-plan --root /tmp/loop-engineering-check --project smoke-project --force --json >/dev/null && node bin/loop-engineering.mjs project-status --root /tmp/loop-engineering-check --project smoke-project --json >/dev/null && node bin/loop-engineering.mjs queue-scheduler-tick --queue smoke --root /tmp/loop-engineering-check --plan-only --force-due --json >/dev/null && node bin/loop-engineering.mjs queue-scheduler-tick --queue smoke-progress --root /tmp/loop-engineering-check --plan-only --force-due --progress-report --progress-report-when-not-due --json >/dev/null && node bin/loop-engineering.mjs workflow-metrics --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs workflow-tune-plan --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs code-queue-init --queue smoke-code --root /tmp/loop-engineering-check --force >/dev/null && node bin/loop-engineering.mjs code-worktree-list --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-task-status --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-task-dashboard --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-worktree-cleanup-plan --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-worktree-cleanup --queue smoke-code --root /tmp/loop-engineering-check --confirm-cleanup --json >/dev/null && node bin/loop-engineering.mjs code-patch-verify --patch README.md --root . --json >/dev/null && node bin/loop-engineering.mjs code-patch-apply-plan --patch README.md --json >/dev/null && node bin/loop-engineering.mjs queue-revision-ci-self-test --queue smoke-ci --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs summarize --root . --json >/dev/null && node bin/loop-engineering.mjs dashboard-health --root . --max-age-seconds 999999999 --json >/dev/null && node bin/loop-engineering.mjs doctor --root . --json >/dev/null",
|
|
27
28
|
"pack:dry": "npm pack --dry-run"
|
|
28
29
|
},
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import { buildFinalJudgement, dispatchFailureClassification, selectEffectiveAcceptanceReviews } from '../lib/core.mjs';
|
|
2
|
+
import { buildFinalJudgement, dispatchFailureClassification, inferTaskScope, selectEffectiveAcceptanceReviews } from '../lib/core.mjs';
|
|
3
3
|
|
|
4
4
|
const basePlan = { rubric: [], automation: [] };
|
|
5
5
|
const baseContract = { task_id: 't1', risk_level: 'L1', requires_human_gate: false, task_scope: 'scoped_task' };
|
|
6
6
|
|
|
7
|
+
assert.equal(inferTaskScope({ body: '开发完整项目并完成整体目标' }), 'project');
|
|
8
|
+
assert.equal(inferTaskScope({ body: '继续同一整体项目,仅推进 T-01 第 2 项;阶段完成后不能称整体项目完成' }), 'scoped_task');
|
|
9
|
+
assert.equal(inferTaskScope({ body: 'Continue the project, only complete boundary item 2' }), 'scoped_task');
|
|
10
|
+
assert.equal(inferTaskScope({ body: '继续同一整体项目,取消团队功能并从项目完成门槛中移除,其他 backlog 不变' }), 'scoped_task');
|
|
11
|
+
assert.equal(inferTaskScope({ body: 'Continue the overall project with a product-scope amendment: remove the team feature from the completion gate.' }), 'scoped_task');
|
|
12
|
+
|
|
7
13
|
{
|
|
8
14
|
const devPlan = { checkpoints: [{ id: 'cp1' }] };
|
|
9
15
|
const reviews = {
|
|
@@ -96,6 +102,19 @@ const baseContract = { task_id: 't1', risk_level: 'L1', requires_human_gate: fal
|
|
|
96
102
|
assert.equal(judgement.outcome, 'project_in_progress');
|
|
97
103
|
}
|
|
98
104
|
|
|
105
|
+
{
|
|
106
|
+
const devPlan = { checkpoints: [{ id: 'cp1' }] };
|
|
107
|
+
const reviews = { reviews: [{
|
|
108
|
+
checkpointId: 'cp1', sequence: 1, status: 'accepted',
|
|
109
|
+
projectCompletion: { status: 'in_progress' },
|
|
110
|
+
deferredGates: [{ id: 'deploy', required_authority: 'owner deployment approval' }]
|
|
111
|
+
}] };
|
|
112
|
+
const judgement = buildFinalJudgement(baseContract, basePlan, devPlan, { count: 1 }, reviews, { dispatchStatus: 'completed' });
|
|
113
|
+
assert.equal(judgement.outcome, 'project_in_progress');
|
|
114
|
+
assert.equal(judgement.coverage.deferred_gates, 1);
|
|
115
|
+
assert.match(judgement.reasons.join(' '), /structured|waiting gates/);
|
|
116
|
+
}
|
|
117
|
+
|
|
99
118
|
{
|
|
100
119
|
const devPlan = { checkpoints: [{ id: 'cp1' }] };
|
|
101
120
|
const reviews = { reviews: [{ checkpointId: 'cp6', sequence: 6, status: 'accepted', projectCompletion: { status: 'accepted' } }] };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { doctorReport, enqueueTask, notifyHumanInputRequests, projectStatus, queueStatus, reconcileProjectGates, routeLoopMessage, taskRuntimeDirFor } from '../lib/core.mjs';
|
|
6
|
+
|
|
7
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'loop-project-gates-'));
|
|
8
|
+
const queue = 'shared';
|
|
9
|
+
const projectFile = path.join(root, 'configs', 'loops', 'projects', 'openreel.json');
|
|
10
|
+
const spec = { schemaVersion: 1, project: 'openreel', type: 'code_project', queues: [{ queue, kind: 'standard' }], backlog: [
|
|
11
|
+
{ id: 'B-01', required: true, status: 'human_gated', dependsOn: [] },
|
|
12
|
+
{ id: 'S-01', required: true, status: 'human_gated', dependsOn: [] }
|
|
13
|
+
] };
|
|
14
|
+
await mkdir(path.dirname(projectFile), { recursive: true });
|
|
15
|
+
await writeFile(projectFile, `${JSON.stringify(spec, null, 2)}\n`);
|
|
16
|
+
|
|
17
|
+
const b = await enqueueTask(root, { queue, title: 'OpenReel B-01', task: 'Project openreel requirement B-01', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
18
|
+
const checkpointDir = path.join(taskRuntimeDirFor(root, queue, b.task.id), 'checkpoints');
|
|
19
|
+
await mkdir(checkpointDir, { recursive: true });
|
|
20
|
+
await writeFile(path.join(checkpointDir, 'cp1.json'), `${JSON.stringify({ version: 1, task_id: b.task.id, checkpoint_id: 'cp1', milestone_id: 'B-01', requirement_ids: ['B-01'], status: 'needs_human_input', blockers: ['Authorize B-01'] }, null, 2)}\n`);
|
|
21
|
+
const notified = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
|
|
22
|
+
assert.equal(notified.sent, 1);
|
|
23
|
+
assert.equal((await queueStatus(root, queue)).waiting, 1);
|
|
24
|
+
const gateFile = path.join(root, notified.results[0].ledger);
|
|
25
|
+
const gate = JSON.parse(await readFile(gateFile, 'utf8'));
|
|
26
|
+
assert.equal(gate.project_id, 'openreel');
|
|
27
|
+
assert.equal(gate.milestone_id, 'B-01');
|
|
28
|
+
assert.deepEqual(gate.requirement_ids, ['B-01']);
|
|
29
|
+
assert.equal(typeof gate.contract_hash, 'string');
|
|
30
|
+
|
|
31
|
+
spec.backlog[0] = { ...spec.backlog[0], required: false, status: 'out_of_scope', disposition: 'canceled_by_owner' };
|
|
32
|
+
await writeFile(projectFile, `${JSON.stringify(spec, null, 2)}\n`);
|
|
33
|
+
const status = await queueStatus(root, queue);
|
|
34
|
+
assert.equal(status.waiting, 0, 'B-01 waiting gate must disappear after B-01 leaves project scope');
|
|
35
|
+
assert.equal(status.canceled, 1);
|
|
36
|
+
assert.equal(JSON.parse(await readFile(gateFile, 'utf8')).status, 'canceled');
|
|
37
|
+
|
|
38
|
+
const other = await enqueueTask(root, { queue, title: 'Other project task', task: 'Project other', projectId: 'other' });
|
|
39
|
+
const openreel = await enqueueTask(root, { queue, title: 'OpenReel target', task: 'Project openreel', projectId: 'openreel' });
|
|
40
|
+
await rename(path.join(root, other.file), path.join(root, 'runtime', 'loops', queue, 'active', path.basename(other.file)));
|
|
41
|
+
await rename(path.join(root, openreel.file), path.join(root, 'runtime', 'loops', queue, 'active', path.basename(openreel.file)));
|
|
42
|
+
const replacement = await routeLoopMessage(root, { route: true, confirmExecute: true, supersedeActive: true, queue, message: '走 loop:继续 openreel 项目' });
|
|
43
|
+
assert.equal(replacement.supersededTaskId, openreel.task.id);
|
|
44
|
+
assert.equal(await readFile(path.join(taskRuntimeDirFor(root, queue, other.task.id), 'supersede_request.json'), 'utf8').then(() => true, () => false), false);
|
|
45
|
+
|
|
46
|
+
const invalidGate = { ...gate, gate_id: `${openreel.task.id}:bad`, task_id: openreel.task.id, checkpoint_id: 'bad', status: 'waiting_for_human', requirement_ids: ['MISSING'] };
|
|
47
|
+
const invalidFile = path.join(root, 'runtime', 'loops', queue, 'human-input', 'gates', `${openreel.task.id}.bad.json`);
|
|
48
|
+
await writeFile(invalidFile, `${JSON.stringify(invalidGate, null, 2)}\n`);
|
|
49
|
+
const doctor = await doctorReport(root);
|
|
50
|
+
assert.equal(doctor.ok, false);
|
|
51
|
+
assert(doctor.checks.some((check) => check.id === `human-gate:${invalidGate.gate_id}` && !check.ok));
|
|
52
|
+
await reconcileProjectGates(root, { queue });
|
|
53
|
+
|
|
54
|
+
// project-status treats the configured ledger/backlog as authoritative and
|
|
55
|
+
// exposes registry drift instead of silently reporting stale completion data.
|
|
56
|
+
const authoritativeBacklog = path.join(root, 'project', 'backlog.json');
|
|
57
|
+
const authoritativeLedger = path.join(root, 'project', 'acceptance-ledger.json');
|
|
58
|
+
await mkdir(path.dirname(authoritativeBacklog), { recursive: true });
|
|
59
|
+
await writeFile(authoritativeBacklog, `${JSON.stringify({ status: 'ongoing', items: [{ id: 'S-01', required: true, status: 'accepted' }] }, null, 2)}\n`);
|
|
60
|
+
await writeFile(authoritativeLedger, `${JSON.stringify({ status: 'ongoing', unmet: ['S-01'], blockers: [{ id: 'S-01' }] }, null, 2)}\n`);
|
|
61
|
+
await writeFile(projectFile, `${JSON.stringify({ ...spec, backlogSource: 'project/backlog.json', acceptanceLedger: 'project/acceptance-ledger.json', terminalContract: 'project/terminal.md' }, null, 2)}\n`);
|
|
62
|
+
const drifted = await projectStatus(root, { project: 'openreel' });
|
|
63
|
+
assert.equal(drifted.projectCompletion, 'in_progress');
|
|
64
|
+
assert.equal(drifted.authority.acceptanceLedger, 'project/acceptance-ledger.json');
|
|
65
|
+
assert.equal(drifted.consistency.ok, false);
|
|
66
|
+
assert(drifted.needsAttention.includes('authoritative_source_drift'));
|
|
67
|
+
|
|
68
|
+
// A ready milestone with project in progress and a deferred authorization is
|
|
69
|
+
// converted into a structured waiting gate, not left as prose on a done task.
|
|
70
|
+
const deferred = await enqueueTask(root, { queue, title: 'OpenReel deferred S-01', task: 'Project openreel S-01', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
71
|
+
const deferredDir = path.join(taskRuntimeDirFor(root, queue, deferred.task.id), 'checkpoints');
|
|
72
|
+
await mkdir(deferredDir, { recursive: true });
|
|
73
|
+
await writeFile(path.join(deferredDir, 'cp-ready.json'), `${JSON.stringify({
|
|
74
|
+
version: 1, task_id: deferred.task.id, checkpoint_id: 'cp-ready', milestone_id: 'S-01', requirement_ids: ['S-01'],
|
|
75
|
+
status: 'ready_for_acceptance', blockers: [], verification: ['local phase passed'], risks: [],
|
|
76
|
+
project_completion: { status: 'in_progress' },
|
|
77
|
+
deferred_gates: [{ id: 'S-01-production', action: 'production_rollback_drill', required_authority: 'Owner authorization for the exact production rollback drill scope.' }]
|
|
78
|
+
}, null, 2)}\n`);
|
|
79
|
+
const deferredNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
|
|
80
|
+
const deferredResult = deferredNotice.results.find((item) => item.taskId === deferred.task.id);
|
|
81
|
+
assert.equal(deferredResult.outcome, 'sent');
|
|
82
|
+
const deferredGate = JSON.parse(await readFile(path.join(root, deferredResult.ledger), 'utf8'));
|
|
83
|
+
assert.equal(deferredGate.gate_kind, 'deferred_authorization');
|
|
84
|
+
assert.equal(deferredGate.authorization_requirements[0].action, 'production_rollback_drill');
|
|
85
|
+
assert.match(deferredGate.authorization_requirements[0].required_authority, /Owner authorization/);
|
|
86
|
+
assert.equal((await queueStatus(root, queue)).waiting >= 1, true);
|
|
87
|
+
|
|
88
|
+
// Once the authoritative project ledger accepts the terminal contract, an
|
|
89
|
+
// optional post-completion deferred action stays in operations backlog and
|
|
90
|
+
// neither creates nor retains a project-queue waiting gate.
|
|
91
|
+
await writeFile(authoritativeLedger, `${JSON.stringify({ status: 'accepted', unmet: [], blockers: [] }, null, 2)}\n`);
|
|
92
|
+
await reconcileProjectGates(root, { queue });
|
|
93
|
+
assert.equal((await queueStatus(root, queue)).waiting, 0);
|
|
94
|
+
assert.equal(JSON.parse(await readFile(path.join(root, deferredResult.ledger), 'utf8')).status, 'superseded');
|
|
95
|
+
const acceptedOptional = await enqueueTask(root, { queue, title: 'OpenReel optional operations transfer', task: 'Project openreel optional post-completion transfer', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
96
|
+
const acceptedOptionalDir = path.join(taskRuntimeDirFor(root, queue, acceptedOptional.task.id), 'checkpoints');
|
|
97
|
+
await mkdir(acceptedOptionalDir, { recursive: true });
|
|
98
|
+
await writeFile(path.join(acceptedOptionalDir, 'cp1.json'), `${JSON.stringify({
|
|
99
|
+
version: 1, task_id: acceptedOptional.task.id, checkpoint_id: 'cp1', milestone_id: 'cp1',
|
|
100
|
+
status: 'ready_for_acceptance', blockers: [], project_completion: { status: 'accepted' },
|
|
101
|
+
deferred_gates: [{ id: 'optional-transfer', required_authority: 'Separate authorization for optional production transfer.' }]
|
|
102
|
+
}, null, 2)}\n`);
|
|
103
|
+
const acceptedNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
|
|
104
|
+
assert.equal(acceptedNotice.results.some((item) => item.taskId === acceptedOptional.task.id), false);
|
|
105
|
+
assert.equal((await queueStatus(root, queue)).waiting, 0);
|
|
106
|
+
|
|
107
|
+
console.log(JSON.stringify({ status: 'ok', assertions: ['structured gate context', 'B-01 waiting to zero', 'project-isolated supersede', 'doctor strong validation', 'authoritative ledger drift', 'ready milestone deferred gate', 'accepted project optional deferred gate stays out of queue'] }));
|
|
@@ -89,6 +89,27 @@ const contract = await readJson(path.join(taskRuntimeDirFor(root, queue, routed.
|
|
|
89
89
|
assert.equal(contract.risk_level, 'model_assessed');
|
|
90
90
|
assert.equal(contract.requires_human_gate, false);
|
|
91
91
|
|
|
92
|
+
const largeBodyQueue = 'large-body-smoke';
|
|
93
|
+
const largeBody = `走 loop 验证超长任务正文\n${'checkpoint evidence '.repeat(10_000)}`;
|
|
94
|
+
const largeBodyRouted = await routeLoopMessage(root, {
|
|
95
|
+
route: true,
|
|
96
|
+
confirmExecute: true,
|
|
97
|
+
queue: largeBodyQueue,
|
|
98
|
+
message: largeBody,
|
|
99
|
+
sourceChannel: 'feishu',
|
|
100
|
+
sourceTarget: 'user-1'
|
|
101
|
+
});
|
|
102
|
+
const largeBodyRun = await runQueueOnce(root, {
|
|
103
|
+
queue: largeBodyQueue,
|
|
104
|
+
dispatcher: `node -e "const fs=require('fs');const t=JSON.parse(fs.readFileSync(process.env.LOOP_TASK_FILE,'utf8'));if(process.env.LOOP_TASK_BODY!==undefined||process.env.LOOP_TASK_BODY_MODE!=='task_file'||t.body.length<100000)process.exit(7)"`,
|
|
105
|
+
progressNotifyCommand: '/bin/true',
|
|
106
|
+
timeoutMs: 10_000,
|
|
107
|
+
leaseMs: 20_000,
|
|
108
|
+
staleActiveMs: 60_000
|
|
109
|
+
});
|
|
110
|
+
assert.equal(largeBodyRun.processed, true);
|
|
111
|
+
assert.equal(largeBodyRun.run.dispatch.exitCode, 0);
|
|
112
|
+
|
|
92
113
|
const orphanQueue = 'orphan-recovery-smoke';
|
|
93
114
|
const orphanRouted = await routeLoopMessage(root, {
|
|
94
115
|
route: true,
|
|
@@ -483,5 +504,35 @@ assert.equal(sent.sent, 1);
|
|
|
483
504
|
const repeated = await notifyTerminalTasks(root, { queue, notifyCommand: '/bin/true' });
|
|
484
505
|
assert.equal(repeated.results[0].outcome, 'already_notified');
|
|
485
506
|
|
|
507
|
+
// Real directory-level regression: terminal history plus the newest project
|
|
508
|
+
// carrier yields exactly one durable gate, one waiting task, and zero replay.
|
|
509
|
+
const regressionRoot = await mkdtemp(path.join(tmpdir(), 'loop-gate-regression-'));
|
|
510
|
+
const regressionQueue = 'project-regression';
|
|
511
|
+
await writeJson(path.join(regressionRoot, 'configs', 'loops', 'projects', 'demo.json'), {
|
|
512
|
+
schemaVersion: 1, project: 'demo', queues: [{ queue: regressionQueue }],
|
|
513
|
+
backlog: [{ id: 'R-1', status: 'human_gated', required: true }]
|
|
514
|
+
});
|
|
515
|
+
for (const [id, enqueuedAt] of [['history-done', '2026-01-01T00:00:00Z'], ['latest-done', '2026-01-02T00:00:00Z']]) {
|
|
516
|
+
await writeJson(path.join(queueSubdirFor(regressionRoot, regressionQueue, 'done'), `${id}.json`), {
|
|
517
|
+
id, title: `demo ${id}`, body: 'demo R-1', projectId: 'demo', status: 'completed', enqueuedAt,
|
|
518
|
+
source: { channel: 'test', target: 'owner' }
|
|
519
|
+
});
|
|
520
|
+
await writeJson(path.join(taskRuntimeDirFor(regressionRoot, regressionQueue, id), 'checkpoints', 'cp1.json'), {
|
|
521
|
+
version: 1, task_id: id, checkpoint_id: 'cp1', milestone_id: 'R-1', sequence: 1,
|
|
522
|
+
status: 'ready_for_acceptance', blockers: [], deferred_gates: [{ id: 'R-1', required_authority: 'Approve R-1.' }],
|
|
523
|
+
verification: [], risks: [], project_completion: 'in_progress', next_action: 'wait'
|
|
524
|
+
});
|
|
525
|
+
await writeJson(path.join(taskRuntimeDirFor(regressionRoot, regressionQueue, id), 'final_judgement.json'), {
|
|
526
|
+
version: 1, task_id: id, outcome: 'project_in_progress', coverage: { effective_review_ids: ['cp1'] }
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
const regressionFirst = await notifyHumanInputRequests(regressionRoot, { queue: regressionQueue, notifyCommand: '/bin/true' });
|
|
530
|
+
assert.equal(regressionFirst.sent, 1);
|
|
531
|
+
assert.equal((await queueStatus(regressionRoot, regressionQueue)).waiting, 1);
|
|
532
|
+
assert.equal((await readdir(path.join(queueDirFor(regressionRoot, regressionQueue), 'human-input', 'gates'))).length, 1);
|
|
533
|
+
assert.equal((await readdir(queueSubdirFor(regressionRoot, regressionQueue, 'done'))).length, 1);
|
|
534
|
+
const regressionRepeated = await notifyHumanInputRequests(regressionRoot, { queue: regressionQueue, notifyCommand: '/bin/true' });
|
|
535
|
+
assert.equal(regressionRepeated.sent, 0);
|
|
536
|
+
|
|
486
537
|
console.log('route/notify self-test passed');
|
|
487
538
|
await import('./config-drift-self-test.mjs');
|