sipeed/picoclaw · error

job not found

Error message

job not found

What it means

UpdateJob iterates cs.store.Jobs looking for a job whose ID equals job.ID; if none matches it returns this error without modifying anything. It is the library's not-found sentinel for the update path. Note it also fires when Start() was never called (store is nil/empty) and after a one-shot 'at' job auto-deleted itself (DeleteAfterRun).

Source

Thrown at pkg/cron/service.go:487

			previous := cs.store.Jobs[i]
			updated := cloneCronJob(*job)
			now := time.Now().UnixMilli()
			updated.UpdatedAtMS = now
			if updated.Enabled {
				if previous.Enabled != updated.Enabled || !sameSchedule(previous.Schedule, updated.Schedule) {
					updated.State.NextRunAtMS = cs.computeNextRun(&updated.Schedule, now)
				}
			} else {
				updated.State.NextRunAtMS = nil
			}
			cs.store.Jobs[i] = updated

			cs.notify()

			return cs.saveStoreUnsafe()
		}
	}
	return fmt.Errorf("job not found")
}

func cloneCronJob(job CronJob) CronJob {
	clone := job
	if job.Schedule.AtMS != nil {
		atMS := *job.Schedule.AtMS
		clone.Schedule.AtMS = &atMS
	}
	if job.Schedule.EveryMS != nil {
		everyMS := *job.Schedule.EveryMS
		clone.Schedule.EveryMS = &everyMS
	}
	if job.State.NextRunAtMS != nil {
		nextRunAtMS := *job.State.NextRunAtMS
		clone.State.NextRunAtMS = &nextRunAtMS
	}
	if job.State.LastRunAtMS != nil {
		lastRunAtMS := *job.State.LastRunAtMS

View on GitHub (pinned to 49183d7e8d)

Solutions

  1. Re-fetch the current ID right before updating: GetJob(jobID) and treat ok==false as expected (job is gone - drop it from your UI/queue)
  2. If the job should exist, verify you are talking to the same service/store: check storePath and ListJobs output
  3. For one-shot 'at' jobs, expect this error after execution - they delete themselves; use AddJob to reschedule
  4. Never reuse zero-value CronJob structs; always start from a GetJob/ListJobs copy and mutate fields you intend to change

Example fix

// before: blind update with a possibly stale copy
if err := svc.UpdateJob(&staleJob); err != nil {
    return err // "job not found" surprises callers
}

// after: guard with GetJob and degrade gracefully
if _, ok := svc.GetJob(staleJob.ID); !ok {
    // job removed or one-shot already fired; nothing to update
    return nil
}
if err := svc.UpdateJob(&staleJob); err != nil {
    if strings.Contains(err.Error(), "job not found") {
        return nil // lost a race with RemoveJob - acceptable
    }
    return err
}
Defensive patterns

Strategy: validation

Validate before calling

// Re-validate immediately before updating; narrows the race window to near zero.
if _, ok := svc.GetJob(job.ID); !ok {
    // job gone (removed or one-shot 'at' already fired): nothing to update
    return nil
}
if err := svc.UpdateJob(job); err != nil { ... }

Type guard

func isJobNotFound(err error) bool {
    return err != nil && strings.Contains(err.Error(), "job not found")
}

Try / catch

if err := svc.UpdateJob(job); err != nil {
    if isJobNotFound(err) {
        // expected after auto-delete of one-shot jobs or a concurrent RemoveJob;
        // refresh your job list rather than surfacing an error
        return refreshJobs()
    }
    return err
}

Prevention

When it happens

Trigger: UpdateJob(&CronJob{ID: "..."}) where the ID came from a stale ListJobs snapshot and another goroutine already RemoveJob'd it; updating a schedule.Kind=="at" job that already ran and was auto-deleted; job.ID empty (zero-value struct); service restarted against a different storePath so old IDs are gone.

Common situations: UI holding a cached job list while the job is removed elsewhere; retrying an update against a restarted service; passing a job copy from a previous deployment; racing RemoveJob vs UpdateJob in concurrent handlers.

Related errors


AI-assisted analysis of sipeed/picoclaw@49183d7e8d (2026-08-15). Data as JSON: /api/errors/d4d9b038a113f313. Report an issue: GitHub.