taskforce-loop-engineering 0.13.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 +10 -0
- package/README.md +15 -1
- package/docs/adapter-sdk-terminal-contract.json +19 -0
- package/docs/production-trust-backlog.json +13 -8
- package/docs/production-trust-contract.md +53 -54
- package/docs/runtime-adapter-sdk.md +50 -0
- package/examples/adapter-sdk-demo.mjs +9 -0
- package/examples/code-worktree-queue.json +35 -0
- package/examples/queue-runner.json +37 -0
- package/examples/safe-canary.mjs +5 -0
- package/examples/workspace-health.json +35 -0
- package/lib/action-reservations.mjs +7 -0
- package/lib/core.mjs +473 -74
- package/lib/execution-ledger.mjs +41 -0
- package/lib/operator-dashboard.mjs +18 -4
- package/lib/production-evidence.mjs +54 -0
- package/lib/runtime-adapter-sdk.mjs +115 -0
- package/package.json +6 -2
- package/scripts/execution-ledger-self-test.mjs +10 -0
- package/scripts/final-judgement-self-test.mjs +20 -1
- package/scripts/live-runtime-soak.mjs +14 -3
- package/scripts/operator-dashboard-self-test.mjs +10 -0
- package/scripts/production-acceptance.mjs +2 -0
- package/scripts/production-evidence-self-test.mjs +17 -0
- package/scripts/production-soak.mjs +41 -17
- package/scripts/project-gate-reconciliation-self-test.mjs +107 -0
- package/scripts/route-notify-self-test.mjs +51 -0
- package/scripts/runtime-adapter-conformance.mjs +21 -0
- package/templates/github-production-trust.yml +20 -0
- package/templates/production-evidence.schema.json +20 -0
package/lib/core.mjs
CHANGED
|
@@ -4,6 +4,8 @@ import { spawn } from 'node:child_process';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
|
+
import { listSteps } from './execution-ledger.mjs';
|
|
8
|
+
import { readAndVerifyEvidence } from './production-evidence.mjs';
|
|
7
9
|
|
|
8
10
|
export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
11
|
|
|
@@ -649,6 +651,7 @@ export async function recentRuns(root, id, options = {}) {
|
|
|
649
651
|
}
|
|
650
652
|
|
|
651
653
|
export async function summarizeLoopRuns(root, options = {}) {
|
|
654
|
+
await reconcileProjectGates(root, options.queue ? { queue: options.queue } : {});
|
|
652
655
|
const ids = await targetRuntimeIds(root, options);
|
|
653
656
|
const summaries = [];
|
|
654
657
|
for (const id of ids) {
|
|
@@ -747,6 +750,38 @@ export async function doctorReport(root, options = {}) {
|
|
|
747
750
|
add('configs-dir', 'warn', await exists(configsDir), path.relative(root, configsDir));
|
|
748
751
|
const runtimeDir = path.join(root, 'runtime', 'loops');
|
|
749
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
|
+
}
|
|
771
|
+
try {
|
|
772
|
+
const steps = await listSteps(root);
|
|
773
|
+
const invalid = steps.filter((step) => step.version !== 2 || !step.step_id || !step.input_fingerprint || !['llm', 'tool', 'effect'].includes(step.kind));
|
|
774
|
+
const reconciliation = steps.filter((step) => step.status === 'unknown' || step.reconciliation?.required);
|
|
775
|
+
add('execution-ledger', 'fail', invalid.length === 0, { schema_version: 2, steps: steps.length, invalid: invalid.map((step) => step.step_id), reconciliation_required: reconciliation.map((step) => step.step_id) });
|
|
776
|
+
if (reconciliation.length) add('execution-ledger:reconciliation', 'warn', false, `${reconciliation.length} step(s) require evidence-backed reconciliation`);
|
|
777
|
+
} catch (error) {
|
|
778
|
+
add('execution-ledger', 'fail', false, error instanceof Error ? error.message : String(error));
|
|
779
|
+
}
|
|
780
|
+
const evidenceFile = path.join(root, '.production-evidence', 'evidence.json');
|
|
781
|
+
if (await exists(evidenceFile)) {
|
|
782
|
+
try { const evidence = await readAndVerifyEvidence(evidenceFile); add('production-evidence', 'fail', evidence.verification.valid && evidence.report.passed, { schema_version: evidence.report.schema_version, integrity: evidence.verification.valid, passed: evidence.report.passed, digest: evidence.report.integrity?.digest }); }
|
|
783
|
+
catch (error) { add('production-evidence', 'fail', false, error instanceof Error ? error.message : String(error)); }
|
|
784
|
+
} else add('production-evidence', 'warn', false, '.production-evidence/evidence.json not generated');
|
|
750
785
|
|
|
751
786
|
const loopConfigs = await configFilesFromArgs(root, []);
|
|
752
787
|
add('loop-configs-found', 'warn', loopConfigs.length > 0, `${loopConfigs.length} loop config(s)`);
|
|
@@ -1009,6 +1044,7 @@ function parkedDisplayState(parked, now = Date.now()) {
|
|
|
1009
1044
|
|
|
1010
1045
|
export async function tickParkedTasks(root, options = {}) {
|
|
1011
1046
|
const queue = normalizeLoopId(options.queue);
|
|
1047
|
+
await reconcileProjectGates(root, { queue });
|
|
1012
1048
|
const now = options.now ? new Date(options.now) : new Date();
|
|
1013
1049
|
if (!Number.isFinite(now.getTime())) throw new Error(`Invalid tick time: ${options.now}`);
|
|
1014
1050
|
if (!options.notifyCommand && !options.dryRun) throw new Error('queue-wait-tick requires --notify-command unless --dry-run is used.');
|
|
@@ -1117,6 +1153,138 @@ function projectConfigPathFor(root, project) {
|
|
|
1117
1153
|
return path.join(root, 'configs', 'loops', 'projects', `${normalizeProjectId(project)}.json`);
|
|
1118
1154
|
}
|
|
1119
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
|
+
|
|
1120
1288
|
function projectLatestIntakePath(root, project) {
|
|
1121
1289
|
return path.join(projectRuntimeDirFor(root, project), 'intake', 'latest.json');
|
|
1122
1290
|
}
|
|
@@ -1578,13 +1746,19 @@ export async function projectStatus(root, options = {}) {
|
|
|
1578
1746
|
} catch {
|
|
1579
1747
|
latestIntake = null;
|
|
1580
1748
|
}
|
|
1581
|
-
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);
|
|
1582
1754
|
let backlog = null;
|
|
1583
1755
|
try {
|
|
1584
1756
|
const loaded = await readJson(backlogFile);
|
|
1585
1757
|
backlog = {
|
|
1586
1758
|
file: path.relative(root, backlogFile),
|
|
1587
|
-
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
|
|
1588
1762
|
};
|
|
1589
1763
|
} catch {
|
|
1590
1764
|
backlog = null;
|
|
@@ -1596,11 +1770,45 @@ export async function projectStatus(root, options = {}) {
|
|
|
1596
1770
|
if (queue.status.locked) acc.locked += 1;
|
|
1597
1771
|
return acc;
|
|
1598
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';
|
|
1599
1805
|
const needsAttention = [];
|
|
1600
1806
|
if (totals.failed > 0) needsAttention.push('failed_tasks_present');
|
|
1601
1807
|
if (totals.waiting > 0) needsAttention.push('human_input_waiting');
|
|
1602
1808
|
if (totals.active > 0) needsAttention.push('active_tasks_present');
|
|
1603
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');
|
|
1604
1812
|
return {
|
|
1605
1813
|
version: 1,
|
|
1606
1814
|
project,
|
|
@@ -1611,11 +1819,22 @@ export async function projectStatus(root, options = {}) {
|
|
|
1611
1819
|
queues,
|
|
1612
1820
|
totals,
|
|
1613
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,
|
|
1614
1831
|
latestIntake,
|
|
1615
1832
|
needsAttention,
|
|
1616
1833
|
nextActions: needsAttention.length
|
|
1617
1834
|
? ['Inspect queue-status or code-task-status for the queue needing attention.']
|
|
1618
|
-
:
|
|
1835
|
+
: projectCompletion === 'accepted'
|
|
1836
|
+
? ['Project terminal contract is accepted.']
|
|
1837
|
+
: ['Enqueue or run the next safe authorized item from the authoritative backlog.']
|
|
1619
1838
|
};
|
|
1620
1839
|
}
|
|
1621
1840
|
|
|
@@ -1643,6 +1862,7 @@ export async function enqueueTask(root, options) {
|
|
|
1643
1862
|
status: 'queued',
|
|
1644
1863
|
enqueuedAt: new Date().toISOString(),
|
|
1645
1864
|
...(options.riskAssessment ? { riskAssessment: options.riskAssessment } : {}),
|
|
1865
|
+
...(options.projectId ? { projectId: normalizeProjectId(options.projectId) } : {}),
|
|
1646
1866
|
...(options.supersedesTaskId ? { supersedesTaskId: options.supersedesTaskId } : {}),
|
|
1647
1867
|
...(options.supersedeReason ? { supersedeReason: options.supersedeReason } : {}),
|
|
1648
1868
|
...(taskSourceFromOptions(options) ? { source: taskSourceFromOptions(options) } : {})
|
|
@@ -1741,6 +1961,7 @@ async function appendActiveTaskAmendment(root, queue, activeTask, options = {})
|
|
|
1741
1961
|
}
|
|
1742
1962
|
|
|
1743
1963
|
export async function routeLoopMessage(root, options = {}) {
|
|
1964
|
+
if (options.queue) await reconcileProjectGates(root, { queue: options.queue });
|
|
1744
1965
|
const classification = classifyLoopMessage(options.message);
|
|
1745
1966
|
if (!options.route) return classification;
|
|
1746
1967
|
if (classification.intent === 'status') {
|
|
@@ -1769,10 +1990,20 @@ export async function routeLoopMessage(root, options = {}) {
|
|
|
1769
1990
|
if (options.supersedeActive && options.amendActive) {
|
|
1770
1991
|
throw new Error('--supersede-active and --amend-active are mutually exclusive.');
|
|
1771
1992
|
}
|
|
1772
|
-
const
|
|
1773
|
-
const
|
|
1774
|
-
|
|
1775
|
-
|
|
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;
|
|
1776
2007
|
if (options.amendActive) {
|
|
1777
2008
|
if (!activeTask) throw new Error('No active loop task exists to amend.');
|
|
1778
2009
|
const amended = await appendActiveTaskAmendment(root, queue, activeTask, options);
|
|
@@ -1788,14 +2019,33 @@ export async function routeLoopMessage(root, options = {}) {
|
|
|
1788
2019
|
};
|
|
1789
2020
|
}
|
|
1790
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
|
+
}
|
|
1791
2048
|
const requestFile = path.join(taskRuntimeDirFor(root, queue, activeTask.id), 'supersede_request.json');
|
|
1792
|
-
await writeJson(requestFile, {
|
|
1793
|
-
version: 1,
|
|
1794
|
-
requestedAt: new Date().toISOString(),
|
|
1795
|
-
activeTaskId: activeTask.id,
|
|
1796
|
-
reason: String(options.message).trim(),
|
|
1797
|
-
status: 'requested'
|
|
1798
|
-
});
|
|
1799
2049
|
for (const file of await listJson(queueSubdirFor(root, queue, 'inbox'))) {
|
|
1800
2050
|
const full = path.join(queueSubdirFor(root, queue, 'inbox'), file);
|
|
1801
2051
|
const queued = await readJson(full);
|
|
@@ -1822,7 +2072,8 @@ export async function routeLoopMessage(root, options = {}) {
|
|
|
1822
2072
|
task,
|
|
1823
2073
|
riskAssessment: 'model_assessed',
|
|
1824
2074
|
supersedesTaskId: activeTask?.id,
|
|
1825
|
-
supersedeReason: activeTask ? String(options.message).trim() : null
|
|
2075
|
+
supersedeReason: activeTask ? String(options.message).trim() : null,
|
|
2076
|
+
projectId: routedProject
|
|
1826
2077
|
});
|
|
1827
2078
|
if (activeTask) {
|
|
1828
2079
|
const requestFile = path.join(taskRuntimeDirFor(root, queue, activeTask.id), 'supersede_request.json');
|
|
@@ -1889,6 +2140,16 @@ export async function notifyTerminalTasks(root, options = {}) {
|
|
|
1889
2140
|
throw new Error('queue-terminal-notify requires --notify-command unless --dry-run is used.');
|
|
1890
2141
|
}
|
|
1891
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
|
+
}
|
|
1892
2153
|
const notificationDir = path.join(queueDirFor(root, queue), 'notifications');
|
|
1893
2154
|
await mkdir(notificationDir, { recursive: true });
|
|
1894
2155
|
const results = [];
|
|
@@ -1897,6 +2158,17 @@ export async function notifyTerminalTasks(root, options = {}) {
|
|
|
1897
2158
|
for (const file of await listJson(dir)) {
|
|
1898
2159
|
const task = await readJson(path.join(dir, file));
|
|
1899
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
|
+
}
|
|
1900
2172
|
if (!task?.source?.channel || !task?.source?.target) {
|
|
1901
2173
|
results.push({
|
|
1902
2174
|
taskId: task.id,
|
|
@@ -2008,8 +2280,10 @@ export async function refreshTaskAcceptance(root, options = {}) {
|
|
|
2008
2280
|
|
|
2009
2281
|
function humanInputMessage(queue, task, checkpoint, gateId, language = 'en') {
|
|
2010
2282
|
const blockers = Array.isArray(checkpoint.blockers) ? checkpoint.blockers : [];
|
|
2011
|
-
const
|
|
2012
|
-
|
|
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))
|
|
2013
2287
|
: [checkpoint.next_action ?? 'Human input is required before the task can continue.'];
|
|
2014
2288
|
if (language === 'zh') return [
|
|
2015
2289
|
'Loop 任务正在等待你的输入',
|
|
@@ -2040,8 +2314,70 @@ async function tasksById(root, queue) {
|
|
|
2040
2314
|
return tasks;
|
|
2041
2315
|
}
|
|
2042
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
|
+
|
|
2043
2378
|
export async function notifyHumanInputRequests(root, options = {}) {
|
|
2044
2379
|
const queue = normalizeLoopId(options.queue);
|
|
2380
|
+
await reconcileProjectGates(root, { queue });
|
|
2045
2381
|
const language = await installedQueueLanguage(root, queue, options.language);
|
|
2046
2382
|
if (!options.notifyCommand && !options.dryRun) {
|
|
2047
2383
|
throw new Error('queue-human-input-notify requires --notify-command unless --dry-run is used.');
|
|
@@ -2051,29 +2387,15 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
2051
2387
|
const gatesDir = path.join(queueDirFor(root, queue), 'human-input', 'gates');
|
|
2052
2388
|
await mkdir(gatesDir, { recursive: true });
|
|
2053
2389
|
const results = [];
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
let checkpointFiles = await listJson(checkpointsDir);
|
|
2058
|
-
const judgementFile = path.join(taskRuntimeDirFor(root, queue, taskId), 'final_judgement.json');
|
|
2059
|
-
if (await exists(judgementFile)) {
|
|
2060
|
-
const judgement = await readJson(judgementFile);
|
|
2061
|
-
const effectiveIds = Array.isArray(judgement?.coverage?.effective_review_ids)
|
|
2062
|
-
? new Set(judgement.coverage.effective_review_ids)
|
|
2063
|
-
: null;
|
|
2064
|
-
if (effectiveIds?.size > 0) {
|
|
2065
|
-
checkpointFiles = checkpointFiles.filter((file) => effectiveIds.has(path.basename(file, '.json')));
|
|
2066
|
-
}
|
|
2067
|
-
}
|
|
2068
|
-
for (const file of checkpointFiles) {
|
|
2069
|
-
const checkpoint = await readJson(path.join(checkpointsDir, file));
|
|
2070
|
-
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 : [];
|
|
2071
2393
|
const checkpointId = checkpoint.checkpoint_id ?? path.basename(file, '.json');
|
|
2072
2394
|
const gateId = `${taskId}:${checkpointId}`;
|
|
2073
2395
|
const ledgerFile = path.join(gatesDir, `${safeTaskId(taskId)}.${normalizeLoopId(checkpointId)}.json`);
|
|
2074
2396
|
if (await exists(ledgerFile)) {
|
|
2075
2397
|
const gate = await readJson(ledgerFile);
|
|
2076
|
-
if (gate.status === 'waiting_for_human' && ['inbox', 'failed'].includes(entry.subdir)) {
|
|
2398
|
+
if (gate.status === 'waiting_for_human' && ['inbox', 'done', 'failed'].includes(entry.subdir)) {
|
|
2077
2399
|
const sourceFile = path.join(queueSubdirFor(root, queue, entry.subdir), `${safeTaskId(taskId)}.json`);
|
|
2078
2400
|
if (await exists(sourceFile)) {
|
|
2079
2401
|
const waitingFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(sourceFile));
|
|
@@ -2081,13 +2403,46 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
2081
2403
|
...entry.task,
|
|
2082
2404
|
status: 'waiting_for_human',
|
|
2083
2405
|
waitingGateId: gateId,
|
|
2406
|
+
waitKind: gate.gate_kind ?? 'human_input',
|
|
2084
2407
|
waitingSince: gate.requested_at ?? new Date().toISOString()
|
|
2085
2408
|
});
|
|
2086
2409
|
await rm(sourceFile, { force: true });
|
|
2087
2410
|
}
|
|
2088
2411
|
}
|
|
2089
|
-
|
|
2090
|
-
|
|
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
|
+
}
|
|
2091
2446
|
}
|
|
2092
2447
|
const message = humanInputMessage(queue, entry.task, checkpoint, gateId, language);
|
|
2093
2448
|
if (options.dryRun) {
|
|
@@ -2108,36 +2463,20 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
2108
2463
|
}
|
|
2109
2464
|
});
|
|
2110
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
|
+
} });
|
|
2111
2471
|
results.push({ taskId, checkpointId, gateId, outcome: 'failed', result: compactCommandResult(result) });
|
|
2112
2472
|
continue;
|
|
2113
2473
|
}
|
|
2114
|
-
await
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
checkpoint_id: checkpointId,
|
|
2120
|
-
status: 'waiting_for_human',
|
|
2121
|
-
source: entry.task.source,
|
|
2122
|
-
request: message,
|
|
2123
|
-
requested_at: new Date().toISOString(),
|
|
2124
|
-
notification: compactCommandResult(result)
|
|
2125
|
-
});
|
|
2126
|
-
if (['inbox', 'failed'].includes(entry.subdir)) {
|
|
2127
|
-
const sourceFile = path.join(queueSubdirFor(root, queue, entry.subdir), `${safeTaskId(taskId)}.json`);
|
|
2128
|
-
if (await exists(sourceFile)) {
|
|
2129
|
-
const waitingFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(sourceFile));
|
|
2130
|
-
await writeJson(waitingFile, {
|
|
2131
|
-
...entry.task,
|
|
2132
|
-
status: 'waiting_for_human',
|
|
2133
|
-
waitingGateId: gateId,
|
|
2134
|
-
waitingSince: new Date().toISOString()
|
|
2135
|
-
});
|
|
2136
|
-
await rm(sourceFile, { force: true });
|
|
2137
|
-
}
|
|
2138
|
-
}
|
|
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
|
+
} });
|
|
2139
2479
|
results.push({ taskId, checkpointId, gateId, outcome: 'sent', ledger: path.relative(root, ledgerFile) });
|
|
2140
|
-
}
|
|
2141
2480
|
}
|
|
2142
2481
|
return {
|
|
2143
2482
|
queue,
|
|
@@ -2450,14 +2789,25 @@ function defaultBlockedActions(riskLevel) {
|
|
|
2450
2789
|
return blocked;
|
|
2451
2790
|
}
|
|
2452
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
|
+
|
|
2453
2806
|
function buildTaskContract(queue, task, options = {}) {
|
|
2454
2807
|
const inferredRisk = inferTaskRisk(task);
|
|
2455
2808
|
const modelAssessed = task.riskAssessment === 'model_assessed';
|
|
2456
2809
|
const riskLevel = options.riskLevel ?? (modelAssessed ? 'model_assessed' : inferredRisk.level);
|
|
2457
|
-
const
|
|
2458
|
-
const taskScope = /project[-_ ]level|项目级|完整项目|整体项目|single milestone|单(?:一)?里程碑/.test(requestText)
|
|
2459
|
-
? 'project'
|
|
2460
|
-
: 'scoped_task';
|
|
2810
|
+
const taskScope = inferTaskScope(task);
|
|
2461
2811
|
return {
|
|
2462
2812
|
version: 1,
|
|
2463
2813
|
task_id: task.id,
|
|
@@ -2568,11 +2918,12 @@ export async function writeTaskContract(root, queue, task, options = {}) {
|
|
|
2568
2918
|
const dir = taskRuntimeDirFor(root, queue, task.id);
|
|
2569
2919
|
const file = path.join(dir, 'task_contract.json');
|
|
2570
2920
|
const historicalPatterns = await retrieveHistoricalPatterns(root, queue, task, options.history ?? {});
|
|
2571
|
-
|
|
2921
|
+
let contract = buildTaskContract(queue, task, {
|
|
2572
2922
|
...options,
|
|
2573
2923
|
workspace: options.workspace ?? root,
|
|
2574
2924
|
historicalPatterns
|
|
2575
2925
|
});
|
|
2926
|
+
contract = await mergeLiveAmendments(root, queue, task.id, contract, 'supplemental_requirements');
|
|
2576
2927
|
await writeJson(file, contract);
|
|
2577
2928
|
return {
|
|
2578
2929
|
contract,
|
|
@@ -2580,6 +2931,36 @@ export async function writeTaskContract(root, queue, task, options = {}) {
|
|
|
2580
2931
|
};
|
|
2581
2932
|
}
|
|
2582
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
|
+
|
|
2583
2964
|
function inferAcceptanceSignals(contract) {
|
|
2584
2965
|
const text = `${contract.title ?? ''}\n${contract.original_request ?? ''}`.toLowerCase();
|
|
2585
2966
|
return {
|
|
@@ -2760,7 +3141,8 @@ function buildAcceptancePlan(contract, options = {}) {
|
|
|
2760
3141
|
export async function writeAcceptancePlan(root, queue, task, taskContract, options = {}) {
|
|
2761
3142
|
const dir = taskRuntimeDirFor(root, queue, task.id);
|
|
2762
3143
|
const file = path.join(dir, 'acceptance_plan.json');
|
|
2763
|
-
|
|
3144
|
+
let plan = buildAcceptancePlan(taskContract.contract, options);
|
|
3145
|
+
plan = await mergeLiveAmendments(root, queue, task.id, plan, 'supplemental_checks');
|
|
2764
3146
|
await writeJson(file, plan);
|
|
2765
3147
|
return {
|
|
2766
3148
|
plan,
|
|
@@ -2827,7 +3209,8 @@ export async function writeDevPlan(root, queue, task, taskContract, acceptancePl
|
|
|
2827
3209
|
await mkdir(checkpointsDir, { recursive: true });
|
|
2828
3210
|
await mkdir(reviewsDir, { recursive: true });
|
|
2829
3211
|
const file = path.join(dir, 'dev_plan.json');
|
|
2830
|
-
|
|
3212
|
+
let plan = buildDevPlan(taskContract.contract, acceptancePlan.plan);
|
|
3213
|
+
plan = await mergeLiveAmendments(root, queue, task.id, plan, 'supplemental_instructions');
|
|
2831
3214
|
await writeJson(file, plan);
|
|
2832
3215
|
return {
|
|
2833
3216
|
plan,
|
|
@@ -3107,6 +3490,7 @@ export async function writeAcceptanceReviews(root, queue, task, taskContract, ac
|
|
|
3107
3490
|
checkpointCreatedAt: checkpoint.created_at ?? null,
|
|
3108
3491
|
createdAt: checkpoint.created_at ?? review.created_at,
|
|
3109
3492
|
projectCompletion: checkpoint.project_completion ?? null,
|
|
3493
|
+
deferredGates: Array.isArray(checkpoint.deferred_gates) ? checkpoint.deferred_gates : [],
|
|
3110
3494
|
status: review.status,
|
|
3111
3495
|
file: path.relative(root, reviewFile)
|
|
3112
3496
|
});
|
|
@@ -3179,6 +3563,8 @@ export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoin
|
|
|
3179
3563
|
const dispatchStatus = context.dispatchStatus ?? 'unknown';
|
|
3180
3564
|
const dispatchFailure = context.dispatchFailureClassification ?? null;
|
|
3181
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);
|
|
3182
3568
|
let outcome = 'needs_revision';
|
|
3183
3569
|
|
|
3184
3570
|
if (dispatchStatus === 'runtime_interrupted') {
|
|
@@ -3229,10 +3615,11 @@ export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoin
|
|
|
3229
3615
|
outcome = 'needs_revision';
|
|
3230
3616
|
reasons.push(`Accepted checkpoints (${acceptedCount}) do not cover required checkpoints (${Math.max(requiredCheckpoints, 1)}).`);
|
|
3231
3617
|
nextActions.push('Complete and review the remaining planned checkpoints.');
|
|
3232
|
-
} else if (contract.task_scope === 'project' && !projectCompletionAccepted) {
|
|
3618
|
+
} else if ((contract.task_scope === 'project' || projectCompletionInProgress || deferredGateCount > 0) && !projectCompletionAccepted) {
|
|
3233
3619
|
outcome = 'project_in_progress';
|
|
3234
3620
|
reasons.push('The latest milestone is accepted, but the project terminal contract is not accepted.');
|
|
3235
|
-
|
|
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.');
|
|
3236
3623
|
} else if (contract.requires_human_gate) {
|
|
3237
3624
|
outcome = 'ready_for_human_review';
|
|
3238
3625
|
reasons.push('All reviewed checkpoints are accepted, and the task contract requires a human gate.');
|
|
@@ -3269,6 +3656,7 @@ export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoin
|
|
|
3269
3656
|
accepted: acceptedCount,
|
|
3270
3657
|
revise: reviseCount,
|
|
3271
3658
|
blocked: blockedCount,
|
|
3659
|
+
deferred_gates: deferredGateCount,
|
|
3272
3660
|
rubric_items: Array.isArray(acceptancePlan?.rubric) ? acceptancePlan.rubric.length : 0,
|
|
3273
3661
|
automation_suggestions: Array.isArray(acceptancePlan?.automation) ? acceptancePlan.automation.length : 0
|
|
3274
3662
|
},
|
|
@@ -3437,6 +3825,7 @@ async function listJson(dir) {
|
|
|
3437
3825
|
export async function queueStatus(root, queue) {
|
|
3438
3826
|
normalizeLoopId(queue);
|
|
3439
3827
|
await ensureQueueDirs(root, queue);
|
|
3828
|
+
await reconcileProjectGates(root, { queue });
|
|
3440
3829
|
const activeFiles = await listJson(queueSubdirFor(root, queue, 'active'));
|
|
3441
3830
|
const lock = await readQueueLock(root, queue);
|
|
3442
3831
|
const lockOwnerAlive = queueLockOwnerAlive(lock);
|
|
@@ -3447,7 +3836,7 @@ export async function queueStatus(root, queue) {
|
|
|
3447
3836
|
taskId: task.id,
|
|
3448
3837
|
status: task.status,
|
|
3449
3838
|
waitId: task.parked?.wait_id ?? task.waitingGateId ?? null,
|
|
3450
|
-
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),
|
|
3451
3840
|
displayState: task.parked ? parkedDisplayState(task.parked) : task.status,
|
|
3452
3841
|
parkedAt: task.parked?.parked_at ?? task.waitingSince ?? null,
|
|
3453
3842
|
reminderCount: Number(task.parked?.reminder_count ?? 0),
|
|
@@ -8137,13 +8526,22 @@ function shellQuote(value) {
|
|
|
8137
8526
|
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
8138
8527
|
}
|
|
8139
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
|
+
|
|
8140
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;
|
|
8141
8538
|
return {
|
|
8142
8539
|
...process.env,
|
|
8143
8540
|
LOOP_QUEUE_ID: queue,
|
|
8144
8541
|
LOOP_TASK_ID: task.id,
|
|
8145
8542
|
LOOP_TASK_TITLE: task.title,
|
|
8146
|
-
LOOP_TASK_BODY:
|
|
8543
|
+
...(inlineTaskBody ? { LOOP_TASK_BODY: taskBody } : {}),
|
|
8544
|
+
LOOP_TASK_BODY_MODE: inlineTaskBody ? 'inline' : 'task_file',
|
|
8147
8545
|
LOOP_TASK_FILE: taskFile,
|
|
8148
8546
|
LOOP_TASK_FILE_REL: path.relative(root, taskFile),
|
|
8149
8547
|
LOOP_RUN_ID: runId,
|
|
@@ -8465,6 +8863,7 @@ async function runPreflight(root, config, timeoutMs) {
|
|
|
8465
8863
|
export async function runQueueOnce(root, options) {
|
|
8466
8864
|
const queue = normalizeLoopId(options.queue);
|
|
8467
8865
|
await ensureQueueDirs(root, queue);
|
|
8866
|
+
await reconcileProjectGates(root, { queue });
|
|
8468
8867
|
let progressTask = null;
|
|
8469
8868
|
const liveProgressNotifications = [];
|
|
8470
8869
|
let liveProgressChain = Promise.resolve();
|