Skip to content

Walking skeleton: recover one generation-bound Task input after post-CAS write failure #47

Description

@chronoai-fkst

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:

  1. Validate that the Task is a non-testing, non-interactive Task with an active claim.
  2. Generate one PendingInputIntent bound to the current claimId and claimGeneration.
  3. Include that intent in the winning Task CAS that changes status to running and extends the lease.
  4. Materialize the sidecar only after the CAS wins.
  5. 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:

  1. Create and claim a non-testing browse Task for worker-a on machine-a.
  2. Have the worker call POST /v1/worker/tasks/{id}/needs-input with the active lease_token, placing the Task in needs_input.
  3. Wrap pending-input materialization so its first call throws before delegating to storage and later calls delegate normally.
  4. Send POST /v1/tasks/{id}/input with literal body {"kind":"text","value":"answer"} and assert HTTP 500 with the exact internal_error envelope above.
  5. 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.
  6. Repeat the identical public request and assert HTTP 200, status: "running", one reused operation ID, and exactly one materialized sidecar.
  7. Poll through POST /v1/worker/tasks/{id}/input/poll; assert the exact input response above.
  8. Poll again; assert the exact empty object response above.
  9. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions