taskforce-loop-engineering 0.15.3 → 0.15.4
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 +7 -0
- package/lib/core.mjs +34 -3
- package/package.json +1 -1
- package/scripts/route-notify-self-test.mjs +92 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.15.4 - 2026-08-22
|
|
6
|
+
|
|
7
|
+
- Preserve source conversation and project metadata across revision planning, direct revision enqueue, and saved-plan application.
|
|
8
|
+
- Refuse to create an unroutable revision when a routed source task has lost its required channel or target.
|
|
9
|
+
- Materialize a human-input gate for the newest authoritative project carrier when it finishes blocked, while continuing to suppress historical failed-task replay.
|
|
10
|
+
- Add regression coverage for routed revision inheritance and blocked project gate delivery.
|
|
11
|
+
|
|
5
12
|
## 0.15.3 - 2026-08-22
|
|
6
13
|
|
|
7
14
|
- Remove workspace-specific Ironman queue and dispatcher instructions from the distributed skill.
|
package/lib/core.mjs
CHANGED
|
@@ -1883,6 +1883,25 @@ function taskSourceFromOptions(options = {}) {
|
|
|
1883
1883
|
return Object.values(source).some(Boolean) ? source : null;
|
|
1884
1884
|
}
|
|
1885
1885
|
|
|
1886
|
+
function taskSourceOptions(source) {
|
|
1887
|
+
if (!source) return {};
|
|
1888
|
+
return {
|
|
1889
|
+
sourceChannel: source.channel ?? undefined,
|
|
1890
|
+
sourceTarget: source.target ?? undefined,
|
|
1891
|
+
sourceAccount: source.account ?? undefined,
|
|
1892
|
+
sourceMessageId: source.message_id ?? undefined,
|
|
1893
|
+
sourceReplyTo: source.reply_to ?? undefined
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
function assertRevisionRoutingSource(task) {
|
|
1898
|
+
const explicitlyRouted = String(task?.body ?? '').startsWith('This task was explicitly routed to Loop Engineering from a source conversation.');
|
|
1899
|
+
if (!task?.source && !explicitlyRouted) return;
|
|
1900
|
+
if (!task?.source?.channel || !task?.source?.target) {
|
|
1901
|
+
throw new Error(`Routed revision source task ${task?.id ?? 'unknown'} is missing source.channel/source.target; refusing to create an unroutable revision.`);
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1886
1905
|
export function classifyLoopMessage(message) {
|
|
1887
1906
|
const text = String(message ?? '').trim();
|
|
1888
1907
|
if (!text) throw new Error('route-message requires --message.');
|
|
@@ -2446,7 +2465,12 @@ async function authoritativeHumanGateCandidates(root, queue, tasks) {
|
|
|
2446
2465
|
if (!current || taskRecency(candidate.entry.task) > taskRecency(current.entry.task)) newestByProject.set(candidate.projectId, candidate);
|
|
2447
2466
|
}
|
|
2448
2467
|
return candidates.filter((candidate) => {
|
|
2449
|
-
if (candidate.projectId)
|
|
2468
|
+
if (candidate.projectId) {
|
|
2469
|
+
const authoritative = newestByProject.get(candidate.projectId) === candidate;
|
|
2470
|
+
const actionableFailed = candidate.entry.subdir === 'failed'
|
|
2471
|
+
&& ['needs_human_input', 'blocked'].includes(candidate.checkpoint.status);
|
|
2472
|
+
return authoritative && (candidate.entry.subdir !== 'failed' || actionableFailed);
|
|
2473
|
+
}
|
|
2450
2474
|
return ['inbox', 'active', 'waiting'].includes(candidate.entry.subdir)
|
|
2451
2475
|
|| (candidate.entry.subdir === 'failed' && ['needs_human_input', 'blocked'].includes(candidate.checkpoint.status));
|
|
2452
2476
|
});
|
|
@@ -5232,6 +5256,7 @@ export async function queueRevisionPlan(root, queue, taskId, options = {}) {
|
|
|
5232
5256
|
const found = await findTaskFile(root, normalized, taskId, ['failed', 'done']);
|
|
5233
5257
|
if (!found) throw new Error(`Task not found for revision: ${taskId}`);
|
|
5234
5258
|
const task = await readJson(found.file);
|
|
5259
|
+
assertRevisionRoutingSource(task);
|
|
5235
5260
|
if (!task.runPath) throw new Error(`Task has no runPath for revision: ${task.id}`);
|
|
5236
5261
|
const run = await readJson(path.join(root, safeRelativePath(task.runPath, 'task runPath')));
|
|
5237
5262
|
const humanDecision = await latestHumanDecision(root, normalized, task.id);
|
|
@@ -5255,6 +5280,8 @@ export async function queueRevisionPlan(root, queue, taskId, options = {}) {
|
|
|
5255
5280
|
const plannedTask = {
|
|
5256
5281
|
title,
|
|
5257
5282
|
body,
|
|
5283
|
+
source: task.source ?? null,
|
|
5284
|
+
projectId: task.projectId ?? task.project_id ?? null,
|
|
5258
5285
|
revisionOf: task.id,
|
|
5259
5286
|
revisionSourceRun: task.runPath,
|
|
5260
5287
|
revisionRequestPath,
|
|
@@ -5288,7 +5315,9 @@ export async function queueRevisionNext(root, queue, taskId, options = {}) {
|
|
|
5288
5315
|
const enqueued = await enqueueTask(root, {
|
|
5289
5316
|
queue: plan.queue,
|
|
5290
5317
|
title: plan.plannedTask.title,
|
|
5291
|
-
task: plan.plannedTask.body
|
|
5318
|
+
task: plan.plannedTask.body,
|
|
5319
|
+
projectId: plan.plannedTask.projectId ?? undefined,
|
|
5320
|
+
...taskSourceOptions(plan.plannedTask.source)
|
|
5292
5321
|
});
|
|
5293
5322
|
const nextTask = {
|
|
5294
5323
|
...enqueued.task,
|
|
@@ -5340,7 +5369,9 @@ export async function queueRevisionApplyPlan(root, planPath, options = {}) {
|
|
|
5340
5369
|
const enqueued = await enqueueTask(root, {
|
|
5341
5370
|
queue: normalized,
|
|
5342
5371
|
title: plan.plannedTask.title,
|
|
5343
|
-
task: plan.plannedTask.body
|
|
5372
|
+
task: plan.plannedTask.body,
|
|
5373
|
+
projectId: plan.plannedTask.projectId ?? undefined,
|
|
5374
|
+
...taskSourceOptions(plan.plannedTask.source)
|
|
5344
5375
|
});
|
|
5345
5376
|
const guard = plan.revisionPolicyGuard ?? plan.plannedTask.revisionPolicyGuard ?? null;
|
|
5346
5377
|
const nextTask = {
|
package/package.json
CHANGED
|
@@ -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');
|