Skip to content

Commit 61c4a7c

Browse files
feat(tasks): add conditional claimability
MoltNet-Diary: c96a5197-c20c-40f9-91c4-88d505d4db36 Task-Group: conditional-task-claimability Task-Family: feature
1 parent d6fd97a commit 61c4a7c

107 files changed

Lines changed: 7871 additions & 890 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/agent-daemon/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ Leave it running. It idles until a task lands in its queue.
250250

251251
In another terminal, with `.moltnet/local-dev/env` sourced. Pick the CLI
252252
form (recommended — schema-validates locally, no Node dependency in the
253-
imposer path) or the template-driven TS form (legacy path that supports
253+
proposer path) or the template-driven TS form (legacy path that supports
254254
`{{placeholder}}` substitution via `--set`).
255255

256256
::: code-group
@@ -325,7 +325,7 @@ pnpm exec tsx tools/src/tasks/create-pr-review.ts \
325325
--repo <owner/repo>
326326
```
327327

328-
This helper stays imposer-only. It reads PR metadata, ensures the PR
328+
This helper stays proposer-only. It reads PR metadata, ensures the PR
329329
correlation marker exists, loads the binary rubric, and creates the
330330
`pr_review` task. The daemon-claimed LLM attempt remains responsible for
331331
the review itself and for any requested outward action such as `gh pr comment`.

apps/agent-daemon/e2e/daemon.e2e.test.ts

Lines changed: 29 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ function buildProducerVerification(inputCid: string) {
6969

7070
/**
7171
* The realistic local-daemon scenario is "one agent, one team, one
72-
* daemon" — the same agent imposes a task and runs the daemon that
72+
* daemon" — the same agent proposes a task and runs the daemon that
7373
* claims it. Cross-agent claiming requires team membership (canAccessTeam
7474
* on /tasks list); a diary grant alone is not sufficient for the list
7575
* endpoint, only for individual claim/heartbeat/complete by id.
@@ -103,7 +103,7 @@ describe('Agent daemon (e2e)', () => {
103103
await harness?.teardown();
104104
});
105105

106-
function imposeCuratePackTask() {
106+
function proposeCuratePackTask() {
107107
return agent.tasks.create({
108108
taskType: 'curate_pack',
109109
teamId,
@@ -115,7 +115,7 @@ describe('Agent daemon (e2e)', () => {
115115
});
116116
}
117117

118-
function imposeFulfillBriefTask(correlationId: string) {
118+
function proposeFulfillBriefTask(correlationId: string) {
119119
return agent.tasks.create({
120120
taskType: 'fulfill_brief',
121121
teamId,
@@ -129,7 +129,10 @@ describe('Agent daemon (e2e)', () => {
129129
});
130130
}
131131

132-
function imposeRunEvalTask(correlationId: string, variantLabel = 'baseline') {
132+
function proposeRunEvalTask(
133+
correlationId: string,
134+
variantLabel = 'baseline',
135+
) {
133136
return agent.tasks.create({
134137
taskType: 'run_eval',
135138
teamId,
@@ -145,7 +148,7 @@ describe('Agent daemon (e2e)', () => {
145148
});
146149
}
147150

148-
function imposeJudgeEvalAttemptTask(
151+
function proposeJudgeEvalAttemptTask(
149152
correlationId: string,
150153
targetTaskId: string,
151154
) {
@@ -177,7 +180,7 @@ describe('Agent daemon (e2e)', () => {
177180
});
178181
}
179182

180-
function imposePrReviewTask() {
183+
function proposePrReviewTask() {
181184
return agent.tasks.create({
182185
taskType: 'pr_review',
183186
teamId,
@@ -218,7 +221,7 @@ describe('Agent daemon (e2e)', () => {
218221
}
219222

220223
it('PollingApiTaskSource claims a queued task and exits drain mode when empty', async () => {
221-
const created = await imposeCuratePackTask();
224+
const created = await proposeCuratePackTask();
222225

223226
const source = new PollingApiTaskSource({
224227
agent: agent,
@@ -249,7 +252,7 @@ describe('Agent daemon (e2e)', () => {
249252
});
250253

251254
it('two parallel claim() calls race on the same task — server CAS picks one winner', async () => {
252-
const created = await imposeCuratePackTask();
255+
const created = await proposeCuratePackTask();
253256

254257
const sourceA = new PollingApiTaskSource({
255258
agent: agent,
@@ -285,7 +288,7 @@ describe('Agent daemon (e2e)', () => {
285288
});
286289

287290
it('runtime.start() drives a full claim → execute → complete loop', async () => {
288-
const created = await imposeCuratePackTask();
291+
const created = await proposeCuratePackTask();
289292

290293
const runtime = new AgentRuntime({
291294
source: new PollingApiTaskSource({
@@ -367,7 +370,7 @@ describe('Agent daemon (e2e)', () => {
367370
}, 60_000);
368371

369372
it('runtime.start() can drive a full pr_review judgment loop with a stub executor', async () => {
370-
const created = await imposePrReviewTask();
373+
const created = await proposePrReviewTask();
371374

372375
const runtime = new AgentRuntime({
373376
source: new PollingApiTaskSource({
@@ -435,7 +438,7 @@ describe('Agent daemon (e2e)', () => {
435438
}, 60_000);
436439

437440
it('fails a claimed attempt when executor throws before reporter.open()', async () => {
438-
const created = await imposeCuratePackTask();
441+
const created = await proposeCuratePackTask();
439442

440443
const runtime = new AgentRuntime({
441444
source: new PollingApiTaskSource({
@@ -469,15 +472,15 @@ describe('Agent daemon (e2e)', () => {
469472
expect(final.status).toBe('failed');
470473
}, 60_000);
471474

472-
it('honors imposer-side cancel — reporter heartbeat trips cancelSignal, runtime returns cancelled', async () => {
473-
// The full cancel contract from #938: imposer cancels the task while
475+
it('honors proposer-side cancel — reporter heartbeat trips cancelSignal, runtime returns cancelled', async () => {
476+
// The full cancel contract from #938: proposer cancels the task while
474477
// the executor is running. The reporter's periodic heartbeat (250ms)
475478
// observes cancelled:true on the next tick, aborts cancelSignal, and
476479
// the executor (which awaits the signal) returns status:'cancelled'
477480
// promptly. The runtime ensures the final output is 'cancelled' even
478481
// if the executor returned anything else. finalizeTask is a no-op
479482
// because the row is already terminal.
480-
const created = await imposeCuratePackTask();
483+
const created = await proposeCuratePackTask();
481484

482485
const runtime = new AgentRuntime({
483486
source: new PollingApiTaskSource({
@@ -500,7 +503,7 @@ describe('Agent daemon (e2e)', () => {
500503
attemptN: claimedTask.attemptN,
501504
});
502505

503-
// Cancel from the imposer side after the first heartbeat.
506+
// Cancel from the proposer side after the first heartbeat.
504507
setTimeout(() => {
505508
void agent.tasks.cancel(claimedTask.task.id, {
506509
reason: 'e2e test cancellation',
@@ -540,7 +543,8 @@ describe('Agent daemon (e2e)', () => {
540543
durationMs: 0,
541544
error: {
542545
code: 'task_cancelled',
543-
message: reporter.cancelReason ?? 'cancelled by imposer during e2e',
546+
message:
547+
reporter.cancelReason ?? 'cancelled by proposer during e2e',
544548
retryable: false,
545549
},
546550
};
@@ -580,7 +584,7 @@ describe('Agent daemon (e2e)', () => {
580584
const warmSessionTtlSec = 60;
581585

582586
try {
583-
const first = await imposeFulfillBriefTask(correlationId);
587+
const first = await proposeFulfillBriefTask(correlationId);
584588
const firstOutput = await runStubbedSlotAwareTask({
585589
agent,
586590
taskId: first.id,
@@ -610,7 +614,7 @@ describe('Agent daemon (e2e)', () => {
610614
expect(existsSync(firstSessionPath!)).toBe(true);
611615
expect(existsSync(firstWorktreePath!)).toBe(true);
612616

613-
const second = await imposeFulfillBriefTask(correlationId);
617+
const second = await proposeFulfillBriefTask(correlationId);
614618
const secondOutput = await runStubbedSlotAwareTask({
615619
agent,
616620
taskId: second.id,
@@ -670,7 +674,7 @@ describe('Agent daemon (e2e)', () => {
670674
const correlationId = randomUUID();
671675
const warmSessionTtlSec = 60;
672676
try {
673-
const producer = await imposeRunEvalTask(correlationId);
677+
const producer = await proposeRunEvalTask(correlationId);
674678
const producerRun = await runStubbedSlotAwareTask({
675679
agent,
676680
taskId: producer.id,
@@ -699,7 +703,7 @@ describe('Agent daemon (e2e)', () => {
699703
expect(existsSync(producerSessionPath!)).toBe(false);
700704
expect(existsSync(producerWorkspacePath!)).toBe(false);
701705

702-
const judge = await imposeJudgeEvalAttemptTask(
706+
const judge = await proposeJudgeEvalAttemptTask(
703707
correlationId,
704708
producer.id,
705709
);
@@ -731,7 +735,7 @@ describe('Agent daemon (e2e)', () => {
731735
// `--task-types`: server filters at SQL level, daemon also pre-
732736
// filters at the source level. No claim-time rejection.
733737

734-
function imposePinnedCuratePackTask(
738+
function proposePinnedCuratePackTask(
735739
allowed: {
736740
provider: string;
737741
model: string;
@@ -747,7 +751,7 @@ describe('Agent daemon (e2e)', () => {
747751
}
748752

749753
it('persists allowedExecutors with lowercased provider/model', async () => {
750-
const created = await imposePinnedCuratePackTask([
754+
const created = await proposePinnedCuratePackTask([
751755
{ provider: 'Anthropic', model: 'Claude-Sonnet-4-5' },
752756
]);
753757
try {
@@ -760,7 +764,7 @@ describe('Agent daemon (e2e)', () => {
760764
});
761765

762766
it('filters out pinned tasks for a non-matching daemon', async () => {
763-
const pinned = await imposePinnedCuratePackTask([
767+
const pinned = await proposePinnedCuratePackTask([
764768
{ provider: 'anthropic', model: 'claude-opus-4-7' },
765769
]);
766770
try {
@@ -778,7 +782,7 @@ describe('Agent daemon (e2e)', () => {
778782
});
779783

780784
it('returns pinned tasks to a matching daemon', async () => {
781-
const pinned = await imposePinnedCuratePackTask([
785+
const pinned = await proposePinnedCuratePackTask([
782786
{ provider: 'anthropic', model: 'claude-sonnet-4-5' },
783787
]);
784788
try {
@@ -796,7 +800,7 @@ describe('Agent daemon (e2e)', () => {
796800
});
797801

798802
it('returns unrestricted tasks regardless of daemon executor', async () => {
799-
const unrestricted = await imposeCuratePackTask();
803+
const unrestricted = await proposeCuratePackTask();
800804
try {
801805
const result = await agent.tasks.list({
802806
teamId,

apps/agent-daemon/src/cli/poll-shared.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ export async function runPolling(opts: PollSharedArgs): Promise<number> {
338338
}
339339
// Pre-execute cancel check. The reporter's first heartbeat
340340
// (fired by `open()`) may already have observed `cancelled:true`
341-
// from the server — e.g. the imposer cancelled between claim and
341+
// from the server — e.g. the proposer cancelled between claim and
342342
// executor entry. Don't burn a VM on work that's already
343343
// terminal. The runtime would override our output anyway via the
344344
// post-execute cancelSignal check, but bailing here saves the

apps/agent-daemon/src/lib/finalize.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export async function finalizeTask(
6060
// we get here, so doing nothing would let the lease expire silently
6161
// and the attempt would surface as `lease_expired` with no signal
6262
// of the real cause. Convert into a terminal `tasks.fail` so the
63-
// attempt carries the actual server-side reason — the next imposer
63+
// attempt carries the actual server-side reason — the next proposer
6464
// (retry, judge, etc.) can read the failure code and act.
6565
const reason = errorToFailReason(err);
6666
ctx.log?.('complete-rejected-falling-back-to-fail', err);
@@ -106,7 +106,7 @@ function errorToFailReason(
106106
`${err.detail ?? err.message}${fields}`,
107107
// The model produced output that violated a server-side rule. A
108108
// bare retry of the same attempt would hit the same rejection.
109-
// Mark non-retryable so the next attempt (or imposer) has a clean
109+
// Mark non-retryable so the next attempt (or proposer) has a clean
110110
// signal that this isn't a transient transport failure.
111111
retryable: false,
112112
};

apps/console/src/pages/TaskDetailPage.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ export function TaskDetailPage({ id }: { id: string }) {
2525
}),
2626
refetchInterval: (query) =>
2727
query.state.data &&
28-
['queued', 'dispatched', 'running'].includes(query.state.data.status)
28+
['waiting', 'queued', 'dispatched', 'running'].includes(
29+
query.state.data.status,
30+
)
2931
? 5_000
3032
: false,
3133
});

apps/console/src/pages/TasksPage.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export function TasksPage() {
4343
refetchInterval: (query) => {
4444
const hasActive = query.state.data?.pages.some((page) =>
4545
page.items.some((task) =>
46-
['queued', 'dispatched', 'running'].includes(task.status),
46+
['waiting', 'queued', 'dispatched', 'running'].includes(task.status),
4747
),
4848
);
4949
return hasActive ? 5_000 : false;
@@ -70,8 +70,8 @@ export function TasksPage() {
7070
<Stack gap={1}>
7171
<Text variant="h2">Tasks</Text>
7272
<Text color="muted">
73-
Track queued, running, failed, and completed work in the active
74-
team.
73+
Track waiting, queued, running, failed, and completed work in the
74+
active team.
7575
</Text>
7676
</Stack>
7777
<Button

apps/console/src/tasks/status.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { TaskStatus } from '@moltnet/api-client';
22

33
export const TASK_STATUS_FILTERS: TaskStatus[] = [
4+
'waiting',
45
'queued',
56
'dispatched',
67
'running',

apps/mcp-server/__tests__/task-tools.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@ const mockTask = {
6262
inputCid: 'bafy-input',
6363
references: [],
6464
correlationId: null,
65-
imposedByAgentId: '330e8400-e29b-41d4-a716-446655440091',
66-
imposedByHumanId: null,
65+
proposedByAgentId: '330e8400-e29b-41d4-a716-446655440091',
66+
proposedByHumanId: null,
6767
acceptedAttemptN: null,
6868
requiredExecutorTrustLevel: 'selfDeclared',
6969
allowedExecutors: [],
@@ -190,7 +190,7 @@ describe('Task tools', () => {
190190
error: 'Forbidden',
191191
message: 'Forbidden',
192192
statusCode: 403,
193-
detail: 'Not authorized to impose tasks on this diary',
193+
detail: 'Not authorized to propose tasks on this diary',
194194
}) as never,
195195
);
196196

@@ -207,7 +207,7 @@ describe('Task tools', () => {
207207

208208
expect(result.isError).toBe(true);
209209
expect(getTextContent(result)).toContain(
210-
'Not authorized to impose tasks on this diary',
210+
'Not authorized to propose tasks on this diary',
211211
);
212212
});
213213

@@ -314,8 +314,8 @@ describe('Task tools', () => {
314314
status: 'queued',
315315
task_type: 'curate_pack',
316316
diary_id: DIARY_ID,
317-
imposed_by_agent_id: '330e8400-e29b-41d4-a716-446655440091',
318-
imposed_by_human_id: '330e8400-e29b-41d4-a716-446655440093',
317+
proposed_by_agent_id: '330e8400-e29b-41d4-a716-446655440091',
318+
proposed_by_human_id: '330e8400-e29b-41d4-a716-446655440093',
319319
claimed_by_agent_id: '330e8400-e29b-41d4-a716-446655440092',
320320
has_attempts: true,
321321
queued_after: '2026-04-28T10:00:00.000Z',
@@ -335,8 +335,8 @@ describe('Task tools', () => {
335335
status: 'queued',
336336
taskTypes: ['curate_pack'],
337337
diaryId: DIARY_ID,
338-
imposedByAgentId: '330e8400-e29b-41d4-a716-446655440091',
339-
imposedByHumanId: '330e8400-e29b-41d4-a716-446655440093',
338+
proposedByAgentId: '330e8400-e29b-41d4-a716-446655440091',
339+
proposedByHumanId: '330e8400-e29b-41d4-a716-446655440093',
340340
claimedByAgentId: '330e8400-e29b-41d4-a716-446655440092',
341341
hasAttempts: true,
342342
queuedAfter: '2026-04-28T10:00:00.000Z',

0 commit comments

Comments
 (0)