An agent can keep extending a conversation even after a task has started, simply by checking whether it is finished. I isolated local waiting as a separate responsibility and wrote a synthetic module with no submission operation. It also distinguishes the end of a local wait from proof that a remote action failed.
I deliberately left out a start operation
wait_existing receives an already known run ID, a trusted scope and a status-reading callback. It has no start, retry, upload or login function. Adding an automatic restart after a wait timeout would turn a convenience feature into a source of duplicate delivery. Deciding whether a new run is necessary remains outside this function.
The adapter accepts one run ID and returns that run's receipt. Calling the adapter read does not prove that its implementation is read-only. The integrating developer must inspect authentication and actual API methods. This synthetic module is not a sandbox capable of controlling arbitrary callback behavior.
Keep the status receipt small and explicit
A receipt accepts only id, scope, status and verified. A different run ID, profile or project stops the wait immediately. A whole manuscript or page attached to the status response is an unknown field and is rejected. The contract makes the bounded nature of repeated status transfer visible in code.
Even succeeded must carry an actual boolean true in verified. The implementation does not coerce an integer one or a string into proof. Failure, cancellation and needs-review each terminate with a distinct code and automatic replay disabled. Only queued and running continue to the next status read.
A wait needs explicit stopping conditions
A monotonic clock defines the deadline, and a separate bound limits the number of reads. Elapsed execution time should not change meaning when wall-clock time changes. Timeout and interval accept finite positive numbers and reject booleans and NaN. Sleeping is capped at the remaining time rather than scheduling past the deadline.
The loop cannot forcibly interrupt a callback that never returns. A real HTTP or pipe adapter needs its own transport timeout. A success returned after this wait's deadline is not counted as a timely success, but the remote operation may still have completed. The next decision should reconcile the original run ID rather than submit another action.
Explore the implementation
Complete independent synthetic implementation
"""Wait for one existing run; no submission/start/retry operation exists here."""
import asyncio,time,math
class WaitError(RuntimeError):
def __init__(self,code,run_id,polls):
self.code=code;self.run_id=run_id;self.polls=polls;self.automatic_replay=False;super().__init__(code)
async def wait_existing(read_status,*,run_id,scope,timeout=30,interval=.1,max_polls=1000,stopped=lambda:False,clock=time.monotonic,sleep=asyncio.sleep):
if not isinstance(run_id,str) or not run_id or not isinstance(scope,dict) or set(scope)!= {'profile','project'} or any(not isinstance(v,str) or not v for v in scope.values()):raise ValueError('INVALID_WAIT_CONTEXT')
if isinstance(timeout,bool) or not isinstance(timeout,(float,int)) or not math.isfinite(timeout) or not 0<timeout<=300:raise ValueError('INVALID_TIMEOUT')
if isinstance(interval,bool) or not isinstance(interval,(float,int)) or not math.isfinite(interval) or not 0<interval<=10:raise ValueError('INVALID_INTERVAL')
if type(max_polls) is not int or not 1<=max_polls<=10000:raise ValueError('INVALID_POLL_LIMIT')
deadline=clock()+timeout;expected=dict(scope);polls=0
while True:
if stopped():raise WaitError('WAIT_STOPPED',run_id,polls)
if clock()>=deadline:raise WaitError('WAIT_TIMEOUT',run_id,polls)
if polls>=max_polls:raise WaitError('WAIT_POLL_LIMIT',run_id,polls)
polls+=1
try:row=await read_status(run_id)
except Exception as e:raise WaitError('STATUS_READ_FAILED',run_id,polls) from e
if stopped():raise WaitError('WAIT_STOPPED',run_id,polls)
if clock()>=deadline:raise WaitError('WAIT_TIMEOUT',run_id,polls)
if not isinstance(row,dict) or set(row)!={'id','scope','status','verified'} or type(row['verified']) is not bool:raise WaitError('INVALID_RECEIPT',run_id,polls)
if row['id']!=run_id or row['scope']!=expected:raise WaitError('RECEIPT_SCOPE_CHANGED',run_id,polls)
status=row['status']
if status=='succeeded':
if not row['verified']:raise WaitError('RESULT_NOT_VERIFIED',run_id,polls)
return {'run_id':run_id,'status':'succeeded','verified':True,'polls':polls,'automatic_replay':False}
if status in ['failed','cancelled','needs_review']:raise WaitError('RUN_'+status.upper(),run_id,polls)
if status not in ['queued','running']:raise WaitError('INVALID_RUN_STATUS',run_id,polls)
await sleep(min(interval,max(0,deadline-clock())))
Check for a stop after the awaited read as well
The stop callback is checked both before a read and after its awaited response. If a person stops during the wait, a late succeeded response must not overwrite that stop. Here stopping means ending local waiting; it does not reverse a change already processed remotely.
A read exception returns STATUS_READ_FAILED rather than starting an unlimited retry loop. Connection failure does not establish that an action was never submitted. The structured exception retains the original run ID and poll count so an operator knows what to inspect later. Persistent storage and restart recovery are deliberately outside this example.
Retain eighteen tests and five example records separately
The tests cover queued-to-running-to-verified completion, unverified success, deadlines and poll limits. They check stops, changed run IDs and projects, failure, cancellation, needs-review, unknown statuses and extra fields. A read exception, a stop during await and a success arriving after the deadline are distinct cases.
Eighteen tests run in source and in the actual extracted ZIP. The example records report zero submission operations because this code contains no submission path. That is not a measurement of every Northstar run or the surrounding conversation's token use. The selector below only reads five synthetic records; it is not waiting on a remote service.
Responsibilities that remain in an operational adapter
The download includes the complete implementation, tests, record generator and README and permits modification and reuse. A real integration still needs trusted authentication context, an actually read-only adapter, transport timeouts and durable receipts. Preventing separate processes from starting the same work requires another ownership mechanism.
I do not claim exactly-once external processing from one small waiting function. Its useful responsibility is narrower: follow a known execution without creating another submission. Even after local waiting ends, there must be a way to inspect the existing run. Convenient automation can begin by deciding which action should not be recreated, rather than adding the next click.