taskforce-loop-engineering 0.15.3 → 0.15.5
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,20 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.15.5 - 2026-08-24
|
|
6
|
+
|
|
7
|
+
- Treat embedded-runtime `incomplete_turn` / abandoned-liveness trailers as recoverable interruptions even when the dispatcher wrapper exits zero, preventing partial work from being accepted or stale human gates from being replayed.
|
|
8
|
+
- Keep future paid, deployment, and publication authorization boundaries deferred while an unrelated safe project backlog item remains actionable.
|
|
9
|
+
- Reconcile an already-materialized premature deferred gate by superseding it and restoring the project carrier to the runnable queue.
|
|
10
|
+
- Resolve the authoritative project from checkpoint milestones when a task still carries a broader parent project id.
|
|
11
|
+
|
|
12
|
+
## 0.15.4 - 2026-08-22
|
|
13
|
+
|
|
14
|
+
- Preserve source conversation and project metadata across revision planning, direct revision enqueue, and saved-plan application.
|
|
15
|
+
- Refuse to create an unroutable revision when a routed source task has lost its required channel or target.
|
|
16
|
+
- Materialize a human-input gate for the newest authoritative project carrier when it finishes blocked, while continuing to suppress historical failed-task replay.
|
|
17
|
+
- Add regression coverage for routed revision inheritance and blocked project gate delivery.
|
|
18
|
+
|
|
5
19
|
## 0.15.3 - 2026-08-22
|
|
6
20
|
|
|
7
21
|
- Remove workspace-specific Ironman queue and dispatcher instructions from the distributed skill.
|
package/lib/core.mjs
CHANGED
|
@@ -1266,17 +1266,32 @@ export async function reconcileProjectGates(root, options = {}) {
|
|
|
1266
1266
|
gate = { ...gate, ...await gateProjectMetadata(root, task, checkpoint) };
|
|
1267
1267
|
await writeJson(full, gate);
|
|
1268
1268
|
}
|
|
1269
|
-
const spec = specs.
|
|
1269
|
+
const spec = await projectSpecForCheckpoint(root, [...specs.values()], gate.project_id, checkpoint);
|
|
1270
|
+
if (spec?.project && gate.project_id !== spec.project) {
|
|
1271
|
+
gate = { ...gate, project_id: spec.project };
|
|
1272
|
+
await writeJson(full, gate);
|
|
1273
|
+
}
|
|
1274
|
+
const safeBacklogActionable = gate.gate_kind === 'deferred_authorization'
|
|
1275
|
+
&& await projectHasSafeActionableBacklog(root, spec);
|
|
1270
1276
|
const invalid = gate.gate_kind === 'deferred_authorization' && await projectTerminalAccepted(root, spec)
|
|
1271
1277
|
? { code: 'project_accepted_optional_deferred', status: 'superseded' }
|
|
1272
|
-
:
|
|
1278
|
+
: safeBacklogActionable
|
|
1279
|
+
? { code: 'safe_backlog_actionable', status: 'superseded' }
|
|
1280
|
+
: gateInvalidity(gate, spec);
|
|
1273
1281
|
if (!invalid) continue;
|
|
1274
1282
|
const now = new Date().toISOString();
|
|
1275
1283
|
const reconciled = { ...gate, status: invalid.status, reconciliation: { ...invalid, reconciled_at: now } };
|
|
1276
1284
|
await writeJson(full, reconciled);
|
|
1277
1285
|
if (found?.subdir === 'waiting' && task?.waitingGateId === gate.gate_id) {
|
|
1278
|
-
const
|
|
1279
|
-
|
|
1286
|
+
const resumeSafeBacklog = invalid.code === 'safe_backlog_actionable';
|
|
1287
|
+
const destinationFile = path.join(queueSubdirFor(root, queue, resumeSafeBacklog ? 'inbox' : 'canceled'), path.basename(found.file));
|
|
1288
|
+
const restored = { ...task, status: resumeSafeBacklog ? 'queued' : invalid.status, gateReconciliation: reconciled.reconciliation };
|
|
1289
|
+
delete restored.waitingGateId;
|
|
1290
|
+
delete restored.waitKind;
|
|
1291
|
+
delete restored.waitingSince;
|
|
1292
|
+
if (!resumeSafeBacklog) restored.canceledAt = now;
|
|
1293
|
+
else restored.requeuedAt = now;
|
|
1294
|
+
await writeJson(destinationFile, restored);
|
|
1280
1295
|
await rm(found.file, { force: true });
|
|
1281
1296
|
}
|
|
1282
1297
|
results.push({ queue, gateId: gate.gate_id, projectId: gate.project_id, outcome: invalid.status, reason: invalid.code });
|
|
@@ -1883,6 +1898,25 @@ function taskSourceFromOptions(options = {}) {
|
|
|
1883
1898
|
return Object.values(source).some(Boolean) ? source : null;
|
|
1884
1899
|
}
|
|
1885
1900
|
|
|
1901
|
+
function taskSourceOptions(source) {
|
|
1902
|
+
if (!source) return {};
|
|
1903
|
+
return {
|
|
1904
|
+
sourceChannel: source.channel ?? undefined,
|
|
1905
|
+
sourceTarget: source.target ?? undefined,
|
|
1906
|
+
sourceAccount: source.account ?? undefined,
|
|
1907
|
+
sourceMessageId: source.message_id ?? undefined,
|
|
1908
|
+
sourceReplyTo: source.reply_to ?? undefined
|
|
1909
|
+
};
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
function assertRevisionRoutingSource(task) {
|
|
1913
|
+
const explicitlyRouted = String(task?.body ?? '').startsWith('This task was explicitly routed to Loop Engineering from a source conversation.');
|
|
1914
|
+
if (!task?.source && !explicitlyRouted) return;
|
|
1915
|
+
if (!task?.source?.channel || !task?.source?.target) {
|
|
1916
|
+
throw new Error(`Routed revision source task ${task?.id ?? 'unknown'} is missing source.channel/source.target; refusing to create an unroutable revision.`);
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1886
1920
|
export function classifyLoopMessage(message) {
|
|
1887
1921
|
const text = String(message ?? '').trim();
|
|
1888
1922
|
if (!text) throw new Error('route-message requires --message.');
|
|
@@ -2380,6 +2414,43 @@ function materializableDeferredGates(checkpoint) {
|
|
|
2380
2414
|
});
|
|
2381
2415
|
}
|
|
2382
2416
|
|
|
2417
|
+
async function projectBacklogItems(root, spec) {
|
|
2418
|
+
if (!spec) return [];
|
|
2419
|
+
const configured = spec.backlogSource ?? spec.authoritativeBacklog;
|
|
2420
|
+
if (!configured) return [];
|
|
2421
|
+
let loaded;
|
|
2422
|
+
try {
|
|
2423
|
+
loaded = await readJson(path.resolve(root, safeRelativePath(configured, 'project backlog source')));
|
|
2424
|
+
} catch {
|
|
2425
|
+
return [];
|
|
2426
|
+
}
|
|
2427
|
+
return Array.isArray(loaded.items) ? loaded.items : Array.isArray(loaded.tasks) ? loaded.tasks : [];
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
async function projectSpecForCheckpoint(root, specs, projectId, checkpoint) {
|
|
2431
|
+
const direct = specs.find((item) => item.project === projectId);
|
|
2432
|
+
const milestoneId = checkpoint?.milestone_id;
|
|
2433
|
+
if (!milestoneId) return direct;
|
|
2434
|
+
if ((await projectBacklogItems(root, direct)).some((item) => item.id === milestoneId)) return direct;
|
|
2435
|
+
for (const spec of specs) {
|
|
2436
|
+
if ((await projectBacklogItems(root, spec)).some((item) => item.id === milestoneId)) return spec;
|
|
2437
|
+
}
|
|
2438
|
+
return direct;
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
async function projectHasSafeActionableBacklog(root, spec) {
|
|
2442
|
+
const items = await projectBacklogItems(root, spec);
|
|
2443
|
+
const byId = new Map(items.map((item) => [item.id, item]));
|
|
2444
|
+
const complete = new Set(['accepted', 'complete', 'completed', 'done', 'phase_complete']);
|
|
2445
|
+
const runnable = new Set(['pending', 'queued', 'ready', 'in_progress', 'active']);
|
|
2446
|
+
return items.some((item) => {
|
|
2447
|
+
if (!item?.id || !runnable.has(item.status)) return false;
|
|
2448
|
+
if (item.requires_human_input === true || item.blocked === true || item.status === 'blocked') return false;
|
|
2449
|
+
const dependencies = Array.isArray(item.dependsOn) ? item.dependsOn : Array.isArray(item.depends_on) ? item.depends_on : [];
|
|
2450
|
+
return dependencies.every((id) => complete.has(byId.get(id)?.status));
|
|
2451
|
+
});
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2383
2454
|
async function tasksById(root, queue) {
|
|
2384
2455
|
const tasks = new Map();
|
|
2385
2456
|
for (const subdir of ['inbox', 'active', 'waiting', 'done', 'failed', 'canceled']) {
|
|
@@ -2433,9 +2504,14 @@ async function authoritativeHumanGateCandidates(root, queue, tasks) {
|
|
|
2433
2504
|
const deferredOnly = latestDeferredGates.length > 0
|
|
2434
2505
|
&& (!Array.isArray(latestCheckpoint.blockers) || latestCheckpoint.blockers.length === 0)
|
|
2435
2506
|
&& !['needs_human_input', 'blocked'].includes(latestCheckpoint.status);
|
|
2436
|
-
const spec =
|
|
2507
|
+
const spec = await projectSpecForCheckpoint(root, specs, metadata.project_id, latestCheckpoint);
|
|
2437
2508
|
if (deferredOnly && await projectTerminalAccepted(root, spec)) continue;
|
|
2438
|
-
|
|
2509
|
+
// deferred_gates describe later authority boundaries. They become a
|
|
2510
|
+
// waiting gate only after the authoritative backlog has no unrelated safe
|
|
2511
|
+
// item left to run. Otherwise a future paid/deploy/publish boundary would
|
|
2512
|
+
// incorrectly stop local project development.
|
|
2513
|
+
if (deferredOnly && await projectHasSafeActionableBacklog(root, spec)) continue;
|
|
2514
|
+
candidates.push({ taskId, entry, projectId: spec?.project ?? metadata.project_id, ...checkpoints[0] });
|
|
2439
2515
|
}
|
|
2440
2516
|
|
|
2441
2517
|
// A project deferred gate is materialized only from its newest authoritative
|
|
@@ -2446,7 +2522,12 @@ async function authoritativeHumanGateCandidates(root, queue, tasks) {
|
|
|
2446
2522
|
if (!current || taskRecency(candidate.entry.task) > taskRecency(current.entry.task)) newestByProject.set(candidate.projectId, candidate);
|
|
2447
2523
|
}
|
|
2448
2524
|
return candidates.filter((candidate) => {
|
|
2449
|
-
if (candidate.projectId)
|
|
2525
|
+
if (candidate.projectId) {
|
|
2526
|
+
const authoritative = newestByProject.get(candidate.projectId) === candidate;
|
|
2527
|
+
const actionableFailed = candidate.entry.subdir === 'failed'
|
|
2528
|
+
&& ['needs_human_input', 'blocked'].includes(candidate.checkpoint.status);
|
|
2529
|
+
return authoritative && (candidate.entry.subdir !== 'failed' || actionableFailed);
|
|
2530
|
+
}
|
|
2450
2531
|
return ['inbox', 'active', 'waiting'].includes(candidate.entry.subdir)
|
|
2451
2532
|
|| (candidate.entry.subdir === 'failed' && ['needs_human_input', 'blocked'].includes(candidate.checkpoint.status));
|
|
2452
2533
|
});
|
|
@@ -5232,6 +5313,7 @@ export async function queueRevisionPlan(root, queue, taskId, options = {}) {
|
|
|
5232
5313
|
const found = await findTaskFile(root, normalized, taskId, ['failed', 'done']);
|
|
5233
5314
|
if (!found) throw new Error(`Task not found for revision: ${taskId}`);
|
|
5234
5315
|
const task = await readJson(found.file);
|
|
5316
|
+
assertRevisionRoutingSource(task);
|
|
5235
5317
|
if (!task.runPath) throw new Error(`Task has no runPath for revision: ${task.id}`);
|
|
5236
5318
|
const run = await readJson(path.join(root, safeRelativePath(task.runPath, 'task runPath')));
|
|
5237
5319
|
const humanDecision = await latestHumanDecision(root, normalized, task.id);
|
|
@@ -5255,6 +5337,8 @@ export async function queueRevisionPlan(root, queue, taskId, options = {}) {
|
|
|
5255
5337
|
const plannedTask = {
|
|
5256
5338
|
title,
|
|
5257
5339
|
body,
|
|
5340
|
+
source: task.source ?? null,
|
|
5341
|
+
projectId: task.projectId ?? task.project_id ?? null,
|
|
5258
5342
|
revisionOf: task.id,
|
|
5259
5343
|
revisionSourceRun: task.runPath,
|
|
5260
5344
|
revisionRequestPath,
|
|
@@ -5288,7 +5372,9 @@ export async function queueRevisionNext(root, queue, taskId, options = {}) {
|
|
|
5288
5372
|
const enqueued = await enqueueTask(root, {
|
|
5289
5373
|
queue: plan.queue,
|
|
5290
5374
|
title: plan.plannedTask.title,
|
|
5291
|
-
task: plan.plannedTask.body
|
|
5375
|
+
task: plan.plannedTask.body,
|
|
5376
|
+
projectId: plan.plannedTask.projectId ?? undefined,
|
|
5377
|
+
...taskSourceOptions(plan.plannedTask.source)
|
|
5292
5378
|
});
|
|
5293
5379
|
const nextTask = {
|
|
5294
5380
|
...enqueued.task,
|
|
@@ -5340,7 +5426,9 @@ export async function queueRevisionApplyPlan(root, planPath, options = {}) {
|
|
|
5340
5426
|
const enqueued = await enqueueTask(root, {
|
|
5341
5427
|
queue: normalized,
|
|
5342
5428
|
title: plan.plannedTask.title,
|
|
5343
|
-
task: plan.plannedTask.body
|
|
5429
|
+
task: plan.plannedTask.body,
|
|
5430
|
+
projectId: plan.plannedTask.projectId ?? undefined,
|
|
5431
|
+
...taskSourceOptions(plan.plannedTask.source)
|
|
5344
5432
|
});
|
|
5345
5433
|
const guard = plan.revisionPolicyGuard ?? plan.plannedTask.revisionPolicyGuard ?? null;
|
|
5346
5434
|
const nextTask = {
|
|
@@ -8700,6 +8788,9 @@ const DEFAULT_REQUIRES_HUMAN_ACTION_PATTERNS = [
|
|
|
8700
8788
|
];
|
|
8701
8789
|
|
|
8702
8790
|
const DEFAULT_RECOVERABLE_RUNTIME_PATTERNS = [
|
|
8791
|
+
{ category: 'incomplete_turn', pattern: '"kind": "incomplete_turn"' },
|
|
8792
|
+
{ category: 'incomplete_turn', pattern: '"livenessState": "abandoned"' },
|
|
8793
|
+
{ category: 'incomplete_turn', pattern: 'stopped before confirming the turn was complete' },
|
|
8703
8794
|
{ category: 'compaction_timeout', pattern: 'compaction timed out' },
|
|
8704
8795
|
{ category: 'compaction_timeout', pattern: 'transcript compaction failed' },
|
|
8705
8796
|
{ category: 'transport_error', pattern: 'connection reset' },
|
|
@@ -8709,7 +8800,28 @@ const DEFAULT_RECOVERABLE_RUNTIME_PATTERNS = [
|
|
|
8709
8800
|
];
|
|
8710
8801
|
|
|
8711
8802
|
export function dispatchFailureClassification(result, retry = {}) {
|
|
8712
|
-
if (!result
|
|
8803
|
+
if (!result) {
|
|
8804
|
+
return {
|
|
8805
|
+
category: 'ok',
|
|
8806
|
+
requiresHumanAction: false,
|
|
8807
|
+
matchedPattern: null
|
|
8808
|
+
};
|
|
8809
|
+
}
|
|
8810
|
+
// Some embedded runtimes exit their wrapper successfully after the model
|
|
8811
|
+
// turn itself was abandoned. Inspect the structured trailer before trusting
|
|
8812
|
+
// exitCode=0 or the queue may accept partial work and replay a stale gate.
|
|
8813
|
+
const output = `${result.stderr ?? ''}\n${result.stdout ?? ''}`.toLowerCase();
|
|
8814
|
+
const runtimePatterns = retry.recoverableRuntimePatterns ?? DEFAULT_RECOVERABLE_RUNTIME_PATTERNS;
|
|
8815
|
+
const runtimeMatch = runtimePatterns.find((entry) => output.includes(String(entry.pattern ?? entry).toLowerCase()));
|
|
8816
|
+
if (runtimeMatch) {
|
|
8817
|
+
return {
|
|
8818
|
+
category: runtimeMatch.category ?? 'runtime_interruption',
|
|
8819
|
+
requiresHumanAction: false,
|
|
8820
|
+
recoverableRuntime: true,
|
|
8821
|
+
matchedPattern: runtimeMatch.pattern ?? runtimeMatch
|
|
8822
|
+
};
|
|
8823
|
+
}
|
|
8824
|
+
if (result.exitCode === 0) {
|
|
8713
8825
|
return {
|
|
8714
8826
|
category: 'ok',
|
|
8715
8827
|
requiresHumanAction: false,
|
|
@@ -8724,7 +8836,6 @@ export function dispatchFailureClassification(result, retry = {}) {
|
|
|
8724
8836
|
matchedPattern: null
|
|
8725
8837
|
};
|
|
8726
8838
|
}
|
|
8727
|
-
const output = `${result.stderr ?? ''}\n${result.stdout ?? ''}`.toLowerCase();
|
|
8728
8839
|
const patterns = retry.requiresHumanActionPatterns ?? DEFAULT_REQUIRES_HUMAN_ACTION_PATTERNS;
|
|
8729
8840
|
const matched = patterns.find((pattern) => output.includes(pattern.toLowerCase()));
|
|
8730
8841
|
if (matched) {
|
|
@@ -8735,16 +8846,6 @@ export function dispatchFailureClassification(result, retry = {}) {
|
|
|
8735
8846
|
matchedPattern: matched
|
|
8736
8847
|
};
|
|
8737
8848
|
}
|
|
8738
|
-
const runtimePatterns = retry.recoverableRuntimePatterns ?? DEFAULT_RECOVERABLE_RUNTIME_PATTERNS;
|
|
8739
|
-
const runtimeMatch = runtimePatterns.find((entry) => output.includes(String(entry.pattern ?? entry).toLowerCase()));
|
|
8740
|
-
if (runtimeMatch) {
|
|
8741
|
-
return {
|
|
8742
|
-
category: runtimeMatch.category ?? 'runtime_interruption',
|
|
8743
|
-
requiresHumanAction: false,
|
|
8744
|
-
recoverableRuntime: true,
|
|
8745
|
-
matchedPattern: runtimeMatch.pattern ?? runtimeMatch
|
|
8746
|
-
};
|
|
8747
|
-
}
|
|
8748
8849
|
return {
|
|
8749
8850
|
category: 'retryable_failure',
|
|
8750
8851
|
requiresHumanAction: false,
|
|
@@ -8877,9 +8978,11 @@ async function runDispatchWithRetry(root, options, queue, task, activeFile, runI
|
|
|
8877
8978
|
result,
|
|
8878
8979
|
failureClassification
|
|
8879
8980
|
});
|
|
8880
|
-
const dispatchStatus =
|
|
8881
|
-
? '
|
|
8882
|
-
:
|
|
8981
|
+
const dispatchStatus = failureClassification.recoverableRuntime
|
|
8982
|
+
? 'interrupted'
|
|
8983
|
+
: result.exitCode === 0
|
|
8984
|
+
? 'passed'
|
|
8985
|
+
: failureClassification.requiresHumanAction ? 'needs_human_action' : 'failed';
|
|
8883
8986
|
runContext.progress?.emit('dispatch', dispatchStatus, `Dispatcher attempt ${attempt} exited ${result.exitCode}`, {
|
|
8884
8987
|
attempt,
|
|
8885
8988
|
exitCode: result.exitCode,
|
|
@@ -9206,6 +9309,10 @@ export async function runQueueOnce(root, options) {
|
|
|
9206
9309
|
const dispatchClassification = dispatchAttempts[dispatchAttempts.length - 1]?.failureClassification ?? null;
|
|
9207
9310
|
if (dispatch?.canceled) {
|
|
9208
9311
|
finalStatus = 'superseded';
|
|
9312
|
+
} else if (dispatchClassification?.recoverableRuntime) {
|
|
9313
|
+
const recoveryCount = Number(task.runtimeRecoveryCount ?? 0);
|
|
9314
|
+
const recoveryMax = Number(options.retry?.runtimeRecoveryMaxAttempts ?? 2);
|
|
9315
|
+
finalStatus = recoveryCount < recoveryMax ? 'runtime_interrupted' : 'runtime_blocked';
|
|
9209
9316
|
} else if (dispatch?.exitCode === 0 && worktreeEnabled(options)) {
|
|
9210
9317
|
verification = await runVerifyCommands(options.worktree?.verifyCommands ?? [], worktree.path, timeoutMs, progress);
|
|
9211
9318
|
const verifyOk = verification.every((entry) => entry.result.exitCode === 0);
|
|
@@ -9215,10 +9322,6 @@ export async function runQueueOnce(root, options) {
|
|
|
9215
9322
|
finalStatus = 'completed';
|
|
9216
9323
|
} else if (dispatchClassification?.requiresHumanAction) {
|
|
9217
9324
|
finalStatus = 'needs_human_input';
|
|
9218
|
-
} else if (dispatchClassification?.recoverableRuntime) {
|
|
9219
|
-
const recoveryCount = Number(task.runtimeRecoveryCount ?? 0);
|
|
9220
|
-
const recoveryMax = Number(options.retry?.runtimeRecoveryMaxAttempts ?? 2);
|
|
9221
|
-
finalStatus = recoveryCount < recoveryMax ? 'runtime_interrupted' : 'runtime_blocked';
|
|
9222
9325
|
} else {
|
|
9223
9326
|
finalStatus = 'failed';
|
|
9224
9327
|
}
|
package/package.json
CHANGED
|
@@ -47,6 +47,36 @@ assert.equal(inferTaskScope({ body: 'Continue the overall project with a product
|
|
|
47
47
|
assert.equal(classification.recoverableRuntime, true);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
{
|
|
51
|
+
const classification = dispatchFailureClassification({
|
|
52
|
+
exitCode: 0,
|
|
53
|
+
timedOut: false,
|
|
54
|
+
stderr: '',
|
|
55
|
+
stdout: JSON.stringify({
|
|
56
|
+
replayInvalid: true,
|
|
57
|
+
livenessState: 'abandoned',
|
|
58
|
+
error: {
|
|
59
|
+
kind: 'incomplete_turn',
|
|
60
|
+
message: 'Codex stopped before confirming the turn was complete. Some work may already have been performed; verify the current state before retrying.'
|
|
61
|
+
}
|
|
62
|
+
}, null, 2)
|
|
63
|
+
});
|
|
64
|
+
assert.equal(classification.category, 'incomplete_turn');
|
|
65
|
+
assert.equal(classification.recoverableRuntime, true);
|
|
66
|
+
assert.equal(classification.requiresHumanAction, false);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
{
|
|
70
|
+
const classification = dispatchFailureClassification({
|
|
71
|
+
exitCode: 0,
|
|
72
|
+
timedOut: false,
|
|
73
|
+
stderr: '',
|
|
74
|
+
stdout: JSON.stringify({ livenessState: 'working', completion: { stopReason: 'stop' } })
|
|
75
|
+
});
|
|
76
|
+
assert.equal(classification.category, 'ok');
|
|
77
|
+
assert.equal(classification.recoverableRuntime, undefined);
|
|
78
|
+
}
|
|
79
|
+
|
|
50
80
|
{
|
|
51
81
|
const devPlan = { checkpoints: [{ id: 'cp1' }] };
|
|
52
82
|
const reviews = { reviews: [{ checkpointId: 'cp38', sequence: 38, status: 'accepted', projectCompletion: { status: 'in_progress' } }] };
|
|
@@ -111,6 +111,28 @@ assert.equal(deferredGate.authorization_requirements[0].action, 'production_roll
|
|
|
111
111
|
assert.match(deferredGate.authorization_requirements[0].required_authority, /Owner authorization/);
|
|
112
112
|
assert.equal((await queueStatus(root, queue)).waiting >= 1, true);
|
|
113
113
|
|
|
114
|
+
// A future authorization must not stop the project while an unrelated safe
|
|
115
|
+
// backlog item remains actionable.
|
|
116
|
+
await reconcileProjectGates(root, { queue });
|
|
117
|
+
await writeFile(authoritativeBacklog, `${JSON.stringify({
|
|
118
|
+
status: 'ongoing',
|
|
119
|
+
items: [
|
|
120
|
+
{ id: 'LOCAL-01', status: 'phase_complete', dependsOn: [] },
|
|
121
|
+
{ id: 'LOCAL-02', status: 'pending', dependsOn: ['LOCAL-01'] }
|
|
122
|
+
]
|
|
123
|
+
}, null, 2)}\n`);
|
|
124
|
+
const futureGateTask = await enqueueTask(root, { queue, title: 'OpenReel future production gate', task: 'Continue safe local backlog', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
125
|
+
const futureGateDir = path.join(taskRuntimeDirFor(root, queue, futureGateTask.task.id), 'checkpoints');
|
|
126
|
+
await mkdir(futureGateDir, { recursive: true });
|
|
127
|
+
await writeFile(path.join(futureGateDir, 'cp1.json'), `${JSON.stringify({
|
|
128
|
+
version: 1, task_id: futureGateTask.task.id, checkpoint_id: 'cp1', status: 'ready_for_acceptance', blockers: [],
|
|
129
|
+
project_completion: { status: 'in_progress' }, next_action: 'Implement LOCAL-02 locally.',
|
|
130
|
+
deferred_gates: [{ id: 'production-later', action: 'production_deploy', required_authority: 'Owner authorization after local candidate acceptance.' }]
|
|
131
|
+
}, null, 2)}\n`);
|
|
132
|
+
const futureNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
|
|
133
|
+
assert.equal(futureNotice.results.some((item) => item.taskId === futureGateTask.task.id), false);
|
|
134
|
+
assert.equal((await queueStatus(root, queue)).waiting, 0);
|
|
135
|
+
|
|
114
136
|
// Conditional policy boundaries are not current blockers. Plain prose in
|
|
115
137
|
// deferred_gates must not materialize a gate without a concrete action and
|
|
116
138
|
// authority requirement.
|
|
@@ -145,4 +167,4 @@ const acceptedNotice = await notifyHumanInputRequests(root, { queue, notifyComma
|
|
|
145
167
|
assert.equal(acceptedNotice.results.some((item) => item.taskId === acceptedOptional.task.id), false);
|
|
146
168
|
assert.equal((await queueStatus(root, queue)).waiting, 0);
|
|
147
169
|
|
|
148
|
-
console.log(JSON.stringify({ status: 'ok', assertions: ['standing project authorization reaches task contract', 'structured gate context', 'B-01 waiting to zero', 'project-isolated supersede', 'doctor strong validation', 'authoritative ledger drift', 'ready milestone deferred gate', 'conditional policy prose does not create a gate', 'accepted project optional deferred gate stays out of queue'] }));
|
|
170
|
+
console.log(JSON.stringify({ status: 'ok', assertions: ['standing project authorization reaches task contract', 'structured gate context', 'B-01 waiting to zero', 'project-isolated supersede', 'doctor strong validation', 'authoritative ledger drift', 'ready milestone deferred gate', 'future gate does not stop safe actionable backlog', 'conditional policy prose does not create a gate', 'accepted project optional deferred gate stays out of queue'] }));
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
notifyHumanInputRequests,
|
|
12
12
|
notifyTerminalTasks,
|
|
13
13
|
queueHumanDecision,
|
|
14
|
+
queueRevisionNext,
|
|
14
15
|
queueDirFor,
|
|
15
16
|
queueStatus,
|
|
16
17
|
queueSubdirFor,
|
|
@@ -562,5 +563,96 @@ assert.equal((await readdir(queueSubdirFor(regressionRoot, regressionQueue, 'don
|
|
|
562
563
|
const regressionRepeated = await notifyHumanInputRequests(regressionRoot, { queue: regressionQueue, notifyCommand: '/bin/true' });
|
|
563
564
|
assert.equal(regressionRepeated.sent, 0);
|
|
564
565
|
|
|
566
|
+
// The newest authoritative project carrier may itself finish blocked. It must
|
|
567
|
+
// become a durable waiting gate instead of being discarded with historical
|
|
568
|
+
// failed attempts.
|
|
569
|
+
const blockedProjectTask = 'latest-blocked';
|
|
570
|
+
await writeJson(path.join(queueSubdirFor(regressionRoot, regressionQueue, 'failed'), `${blockedProjectTask}.json`), {
|
|
571
|
+
id: blockedProjectTask,
|
|
572
|
+
title: 'demo latest blocked',
|
|
573
|
+
body: 'demo R-1',
|
|
574
|
+
projectId: 'demo',
|
|
575
|
+
status: 'blocked',
|
|
576
|
+
enqueuedAt: '2026-01-03T00:00:00Z',
|
|
577
|
+
source: { channel: 'feishu', target: 'owner' }
|
|
578
|
+
});
|
|
579
|
+
await writeJson(path.join(taskRuntimeDirFor(regressionRoot, regressionQueue, blockedProjectTask), 'checkpoints', 'cp2.json'), {
|
|
580
|
+
version: 1,
|
|
581
|
+
task_id: blockedProjectTask,
|
|
582
|
+
checkpoint_id: 'cp2',
|
|
583
|
+
milestone_id: 'R-1',
|
|
584
|
+
sequence: 2,
|
|
585
|
+
status: 'needs_human_input',
|
|
586
|
+
blockers: ['Approve the production activation.'],
|
|
587
|
+
verification: [],
|
|
588
|
+
risks: [],
|
|
589
|
+
project_completion: 'in_progress',
|
|
590
|
+
next_action: 'wait'
|
|
591
|
+
});
|
|
592
|
+
await writeJson(path.join(taskRuntimeDirFor(regressionRoot, regressionQueue, blockedProjectTask), 'final_judgement.json'), {
|
|
593
|
+
version: 1,
|
|
594
|
+
task_id: blockedProjectTask,
|
|
595
|
+
outcome: 'blocked',
|
|
596
|
+
coverage: { effective_review_ids: ['cp2'] }
|
|
597
|
+
});
|
|
598
|
+
const blockedProjectGate = await notifyHumanInputRequests(regressionRoot, { queue: regressionQueue, notifyCommand: '/bin/true' });
|
|
599
|
+
assert.equal(blockedProjectGate.sent, 1);
|
|
600
|
+
assert.equal((await readJson(path.join(queueSubdirFor(regressionRoot, regressionQueue, 'waiting'), `${blockedProjectTask}.json`))).status, 'waiting_for_human');
|
|
601
|
+
|
|
602
|
+
// A routed revision must preserve the complete source envelope so a later
|
|
603
|
+
// human gate or terminal result returns to the originating conversation.
|
|
604
|
+
const revisionQueue = 'revision-routing';
|
|
605
|
+
const revisionSource = await routeLoopMessage(root, {
|
|
606
|
+
route: true,
|
|
607
|
+
confirmExecute: true,
|
|
608
|
+
supersedeActive: true,
|
|
609
|
+
queue: revisionQueue,
|
|
610
|
+
message: '走 loop fix revision routing',
|
|
611
|
+
sourceChannel: 'feishu',
|
|
612
|
+
sourceTarget: 'user-revision',
|
|
613
|
+
sourceAccount: 'main',
|
|
614
|
+
sourceMessageId: 'revision-source-message',
|
|
615
|
+
sourceReplyTo: 'revision-source-reply'
|
|
616
|
+
});
|
|
617
|
+
const revisionSourceFile = path.join(queueSubdirFor(root, revisionQueue, 'inbox'), `${revisionSource.task.id}.json`);
|
|
618
|
+
const revisionFailedFile = path.join(queueSubdirFor(root, revisionQueue, 'failed'), `${revisionSource.task.id}.json`);
|
|
619
|
+
const revisionRequestFile = path.join(taskRuntimeDirFor(root, revisionQueue, revisionSource.task.id), 'revision_request.json');
|
|
620
|
+
const revisionRunFile = path.join(queueSubdirFor(root, revisionQueue, 'runs'), `${revisionSource.task.id}.json`);
|
|
621
|
+
await writeJson(revisionRequestFile, {
|
|
622
|
+
version: 1,
|
|
623
|
+
task_id: revisionSource.task.id,
|
|
624
|
+
status: 'requested',
|
|
625
|
+
revision_goals: [{ check: 'routing', required_change: 'Preserve source routing.' }],
|
|
626
|
+
next_checkpoint: { suggested_id: 'cp2' }
|
|
627
|
+
});
|
|
628
|
+
await writeJson(revisionRunFile, {
|
|
629
|
+
version: 2,
|
|
630
|
+
taskId: revisionSource.task.id,
|
|
631
|
+
status: 'failed',
|
|
632
|
+
finalJudgement: { outcome: 'needs_revision' },
|
|
633
|
+
revisionRequest: { path: path.relative(root, revisionRequestFile) }
|
|
634
|
+
});
|
|
635
|
+
await writeJson(revisionFailedFile, {
|
|
636
|
+
...revisionSource.task,
|
|
637
|
+
status: 'failed',
|
|
638
|
+
projectId: 'demo-project',
|
|
639
|
+
runPath: path.relative(root, revisionRunFile)
|
|
640
|
+
});
|
|
641
|
+
await rm(revisionSourceFile, { force: true });
|
|
642
|
+
const revision = await queueRevisionNext(root, revisionQueue, revisionSource.task.id, {
|
|
643
|
+
force: true,
|
|
644
|
+
strategy: 'Preserve the source envelope and verify blocked notification delivery.'
|
|
645
|
+
});
|
|
646
|
+
assert.deepEqual(revision.nextTask.source, revisionSource.task.source);
|
|
647
|
+
assert.equal(revision.nextTask.projectId, 'demo-project');
|
|
648
|
+
|
|
649
|
+
const unroutable = { ...await readJson(revisionFailedFile) };
|
|
650
|
+
delete unroutable.source;
|
|
651
|
+
await writeJson(revisionFailedFile, unroutable);
|
|
652
|
+
await assert.rejects(
|
|
653
|
+
queueRevisionNext(root, revisionQueue, revisionSource.task.id, { force: true }),
|
|
654
|
+
/refusing to create an unroutable revision/
|
|
655
|
+
);
|
|
656
|
+
|
|
565
657
|
console.log('route/notify self-test passed');
|
|
566
658
|
await import('./config-drift-self-test.mjs');
|