Objective
Implement the thinnest executable end-to-end path proving that a non-testing Task input survives a failure after the winning Task CAS and is delivered to the owning worker exactly once.
This child covers one accepted behavior only: an identical client retry reconciles a pending text input after the first sidecar materialization attempt throws before writing. The path must run through the public HTTP route, TaskService, the atomically persisted Task authority, both repository implementations, and the worker polling route.
Required internal authority
Add an internal optional Task field named pendingInputIntent with this exact TypeScript shape:
interface PendingInputIntent {
schemaVersion: 'talos.task-input-intent/v1';
operationId: string;
taskId: string;
claimId: string;
claimGeneration: number;
input: TaskInput;
}
TaskInput remains exactly:
interface TaskInput {
kind: 'choice' | 'text' | 'otp';
value: string;
}
For the skeleton, operationId is an opaque internally generated identifier. It must be generated before the winning Task CAS and reused from pendingInputIntent during reconciliation. It must never be accepted from or returned to a client.
The winning needs_input -> running Task replacement must atomically persist pendingInputIntent with the exact active claimId and positive claimGeneration. Do not write a pending-input sidecar before this CAS succeeds. A losing CAS must produce zero pending-input materialization calls.
Persist a sidecar record containing the intent fields plus consumed: boolean. Materialization must be insert-once and idempotent by operationId: encountering the same complete record is success, and must never change consumed: true back to false. A record with the same operationId but different taskId, claim binding, input kind, or input value is an internal integrity failure, not an overwrite.
Consumption must atomically change exactly one matching record from consumed: false to consumed: true and return its input. Match all of operationId, taskId, claimId, and claimGeneration. A repeated consume returns undefined. A record from another claim ID or generation must never be returned.
Expose equivalent behavior from MemoryRepository and MongoRepository. Standalone MongoDB must remain supported; do not add transactions, sessions, or replica-set requirements. Mongo materialization must use single-document insert/upsert semantics that cannot reset an existing consumed record, and consumption must use a single atomic conditional update.
After successful normal consumption, clear pendingInputIntent from the Task through the existing active-claim CAS seam. This cleanup must not make the already-consumed input available again if cleanup loses contention.
Service behavior
For a Task in needs_input, provideInput() must:
- Validate that the Task is a non-testing, non-interactive Task with an active claim.
- Generate one
PendingInputIntent bound to the current claimId and claimGeneration.
- Include that intent in the winning Task CAS that changes status to
running and extends the lease.
- Materialize the sidecar only after the CAS wins.
- Return the existing public Task response after materialization succeeds.
If materialization throws, leave the authoritative intent in the persisted Task and propagate the failure.
For a later identical request, if the Task is already running and has a pendingInputIntent whose taskId, active claimId, active claimGeneration, input.kind, and input.value exactly match the request, treat the request as reconciliation: reuse its operationId, materialize idempotently, and return HTTP 200. Do not perform another state transition and do not create another intent.
Input equality is exact JavaScript string equality for both kind and value. Do not trim, normalize, case-fold, parse, or otherwise transform value. JSON object field order is not significant.
Do not broaden retries for a different payload, a different claim binding, an absent intent, or any status other than the exact reconciliation case. Existing conflict and concurrent_update classification remains unchanged.
External HTTP contract
Do not change the OpenAPI or public wire schema.
The public request remains:
POST /v1/tasks/{id}/input
Content-Type: application/json
X-NyxID-Identity-Token: user:user-a
{"kind":"text","value":"answer"}
The body must contain kind and value. kind is exactly one of choice, text, or otp; value is a non-empty string of at most 10,000 characters.
On successful initial materialization or successful reconciliation, return HTTP 200 with the existing public Task representation and status equal to running.
When the injected first materialization attempt throws an ordinary error, the first HTTP response must remain:
{"error":{"code":"internal_error","message":"internal server error","retryable":true}}
with HTTP status 500.
The proxy-compatible worker poll remains:
POST /v1/worker/tasks/{id}/input/poll
Content-Type: application/json
{"lease_token":"<active lease token>","worker_token":"worker-token-123456","worker_id":"worker-a","machine_id":"machine-a"}
The first successful poll for the bound intent must return HTTP 200 with exactly:
{"input":{"kind":"text","value":"answer"}}
The next poll with the same active worker credentials must return HTTP 200 with exactly:
Internal pendingInputIntent, operationId, claimId, claimGeneration, sidecar state, and tokens must remain absent from public Task responses, errors, webhook payloads, and logs.
TDD acceptance test
Add a deterministic HTTP acceptance test using MemoryRepository or a repository wrapper; do not use sleeps or timing assumptions.
The test must:
- Create and claim a non-testing
browse Task for worker-a on machine-a.
- Have the worker call
POST /v1/worker/tasks/{id}/needs-input with the active lease_token, placing the Task in needs_input.
- Wrap pending-input materialization so its first call throws before delegating to storage and later calls delegate normally.
- Send
POST /v1/tasks/{id}/input with literal body {"kind":"text","value":"answer"} and assert HTTP 500 with the exact internal_error envelope above.
- Inspect repository state and prove the Task is already
running, retains one authoritative intent, and that intent uses the Task's active claimId and claimGeneration; prove no sidecar was written by the thrown call.
- Repeat the identical public request and assert HTTP 200,
status: "running", one reused operation ID, and exactly one materialized sidecar.
- Poll through
POST /v1/worker/tasks/{id}/input/poll; assert the exact input response above.
- Poll again; assert the exact empty object response above.
- Assert all public Task responses omit every internal intent and claim field.
Add a focused repository contract executed against both MemoryRepository and MongoRepository proving that identical materialization is idempotent, consumption succeeds once, repeated consumption returns undefined, and materialization after consumption cannot resurrect the record.
Write the tests first and leave them green. Run at minimum the targeted service/HTTP/repository tests, npm run build, and npm run typecheck.
Boundaries
- Cover generic non-testing
browse/computer_use Task input only.
- Do not implement handoff durability in this child.
- Do not add background restart reconciliation; the accepted convergence trigger here is the identical client retry.
- Do not implement the full thrown-write, lost-acknowledgement, standalone-Mongo restart, expiry/requeue, or generation-N-to-N+1 matrices.
- Do not change Testing Tool, QARun, provider, deployment, Session action, terminal-event, completed-result indexing, or external schema contracts.
- Preserve three bounded user CAS retries, HTTP 409
concurrent_update for genuine CAS exhaustion, non-retryable inactive-claim conflicts, and zero sidecar writes after a losing CAS.
- This must be real executable architecture, not interfaces with placeholder implementations or skipped tests. If no mergeable architectural seam exists, make no changes and report the precise reason; that outcome is fatal for this child.
Objective
Implement the thinnest executable end-to-end path proving that a non-testing Task input survives a failure after the winning Task CAS and is delivered to the owning worker exactly once.
This child covers one accepted behavior only: an identical client retry reconciles a pending text input after the first sidecar materialization attempt throws before writing. The path must run through the public HTTP route,
TaskService, the atomically persisted Task authority, both repository implementations, and the worker polling route.Required internal authority
Add an internal optional Task field named
pendingInputIntentwith this exact TypeScript shape:TaskInputremains exactly:For the skeleton,
operationIdis an opaque internally generated identifier. It must be generated before the winning Task CAS and reused frompendingInputIntentduring reconciliation. It must never be accepted from or returned to a client.The winning
needs_input -> runningTask replacement must atomically persistpendingInputIntentwith the exact activeclaimIdand positiveclaimGeneration. Do not write a pending-input sidecar before this CAS succeeds. A losing CAS must produce zero pending-input materialization calls.Persist a sidecar record containing the intent fields plus
consumed: boolean. Materialization must be insert-once and idempotent byoperationId: encountering the same complete record is success, and must never changeconsumed: trueback tofalse. A record with the sameoperationIdbut differenttaskId, claim binding, input kind, or input value is an internal integrity failure, not an overwrite.Consumption must atomically change exactly one matching record from
consumed: falsetoconsumed: trueand return its input. Match all ofoperationId,taskId,claimId, andclaimGeneration. A repeated consume returnsundefined. A record from another claim ID or generation must never be returned.Expose equivalent behavior from
MemoryRepositoryandMongoRepository. Standalone MongoDB must remain supported; do not add transactions, sessions, or replica-set requirements. Mongo materialization must use single-document insert/upsert semantics that cannot reset an existing consumed record, and consumption must use a single atomic conditional update.After successful normal consumption, clear
pendingInputIntentfrom the Task through the existing active-claim CAS seam. This cleanup must not make the already-consumed input available again if cleanup loses contention.Service behavior
For a Task in
needs_input,provideInput()must:PendingInputIntentbound to the currentclaimIdandclaimGeneration.runningand extends the lease.If materialization throws, leave the authoritative intent in the persisted Task and propagate the failure.
For a later identical request, if the Task is already
runningand has apendingInputIntentwhosetaskId, activeclaimId, activeclaimGeneration,input.kind, andinput.valueexactly match the request, treat the request as reconciliation: reuse itsoperationId, materialize idempotently, and return HTTP 200. Do not perform another state transition and do not create another intent.Input equality is exact JavaScript string equality for both
kindandvalue. Do not trim, normalize, case-fold, parse, or otherwise transformvalue. JSON object field order is not significant.Do not broaden retries for a different payload, a different claim binding, an absent intent, or any status other than the exact reconciliation case. Existing conflict and
concurrent_updateclassification remains unchanged.External HTTP contract
Do not change the OpenAPI or public wire schema.
The public request remains:
The body must contain
kindandvalue.kindis exactly one ofchoice,text, orotp;valueis a non-empty string of at most 10,000 characters.On successful initial materialization or successful reconciliation, return HTTP 200 with the existing public Task representation and
statusequal torunning.When the injected first materialization attempt throws an ordinary error, the first HTTP response must remain:
{"error":{"code":"internal_error","message":"internal server error","retryable":true}}with HTTP status 500.
The proxy-compatible worker poll remains:
The first successful poll for the bound intent must return HTTP 200 with exactly:
{"input":{"kind":"text","value":"answer"}}The next poll with the same active worker credentials must return HTTP 200 with exactly:
{}Internal
pendingInputIntent,operationId,claimId,claimGeneration, sidecar state, and tokens must remain absent from public Task responses, errors, webhook payloads, and logs.TDD acceptance test
Add a deterministic HTTP acceptance test using
MemoryRepositoryor a repository wrapper; do not use sleeps or timing assumptions.The test must:
browseTask forworker-aonmachine-a.POST /v1/worker/tasks/{id}/needs-inputwith the activelease_token, placing the Task inneeds_input.POST /v1/tasks/{id}/inputwith literal body{"kind":"text","value":"answer"}and assert HTTP 500 with the exactinternal_errorenvelope above.running, retains one authoritative intent, and that intent uses the Task's activeclaimIdandclaimGeneration; prove no sidecar was written by the thrown call.status: "running", one reused operation ID, and exactly one materialized sidecar.POST /v1/worker/tasks/{id}/input/poll; assert the exact input response above.Add a focused repository contract executed against both
MemoryRepositoryandMongoRepositoryproving that identical materialization is idempotent, consumption succeeds once, repeated consumption returnsundefined, and materialization after consumption cannot resurrect the record.Write the tests first and leave them green. Run at minimum the targeted service/HTTP/repository tests,
npm run build, andnpm run typecheck.Boundaries
browse/computer_useTask input only.concurrent_updatefor genuine CAS exhaustion, non-retryable inactive-claim conflicts, and zero sidecar writes after a losing CAS.