taskcheck 1.5.2__tar.gz → 1.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {taskcheck-1.5.2/taskcheck.egg-info → taskcheck-1.6.0}/PKG-INFO +42 -15
- {taskcheck-1.5.2 → taskcheck-1.6.0}/README.md +41 -14
- {taskcheck-1.5.2 → taskcheck-1.6.0}/pyproject.toml +1 -1
- taskcheck-1.6.0/taskcheck/agent_blocks.py +160 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0}/taskcheck/common.py +19 -1
- {taskcheck-1.5.2 → taskcheck-1.6.0}/taskcheck/install.py +4 -1
- {taskcheck-1.5.2 → taskcheck-1.6.0}/taskcheck/parallel.py +24 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0/taskcheck.egg-info}/PKG-INFO +42 -15
- {taskcheck-1.5.2 → taskcheck-1.6.0}/taskcheck.egg-info/SOURCES.txt +3 -0
- taskcheck-1.6.0/tests/test_agent_blocks.py +196 -0
- taskcheck-1.6.0/tests/test_agent_scheduler_integration.py +153 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0}/LICENSE +0 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0}/setup.cfg +0 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0}/taskcheck/__main__.py +0 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0}/taskcheck/dry_run.py +0 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0}/taskcheck/google_calendar.py +0 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0}/taskcheck/ical.py +0 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0}/taskcheck/report.py +0 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0}/taskcheck/theme.py +0 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0}/taskcheck/timeline.py +0 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0}/taskcheck/undo.py +0 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0}/taskcheck.egg-info/dependency_links.txt +0 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0}/taskcheck.egg-info/entry_points.txt +0 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0}/taskcheck.egg-info/requires.txt +0 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0}/taskcheck.egg-info/top_level.txt +0 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0}/tests/test_common.py +0 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0}/tests/test_google_calendar.py +0 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0}/tests/test_ical.py +0 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0}/tests/test_install.py +0 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0}/tests/test_main.py +0 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0}/tests/test_parallel.py +0 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0}/tests/test_report.py +0 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0}/tests/test_timeline.py +0 -0
- {taskcheck-1.5.2 → taskcheck-1.6.0}/tests/test_undo.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: taskcheck
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.0
|
|
4
4
|
Summary: Automatic scheduler for taskwarrior
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -13,8 +13,6 @@ Requires-Dist: random-unicode-emoji>=2.8
|
|
|
13
13
|
Requires-Dist: requests>=2.32.3
|
|
14
14
|
Requires-Dist: rich>=13.9.4
|
|
15
15
|
|
|
16
|
-

|
|
17
|
-
|
|
18
16
|
A Taskwarrior scheduler for people who want a realistic plan, not a manual to-do list.
|
|
19
17
|
It turns tasks, working hours, and calendar blocks into an actionable schedule — then keeps due dates visible.
|
|
20
18
|
|
|
@@ -23,6 +21,7 @@ Use it if you want to:
|
|
|
23
21
|
- fit tasks into real availability
|
|
24
22
|
- be sure your long-time plan won't go overdue
|
|
25
23
|
- keep Taskwarrior as the source of truth
|
|
24
|
+
- handle your time across multiple tasks carried on by LLMs
|
|
26
25
|
|
|
27
26
|
> [!IMPORTANT]
|
|
28
27
|
> This repo is actively maintained again
|
|
@@ -30,7 +29,7 @@ Use it if you want to:
|
|
|
30
29
|
## Features
|
|
31
30
|
|
|
32
31
|
- ✨ Auto-schedule tasks from working hours + calendar blocks
|
|
33
|
-
-
|
|
32
|
+
- 🧠 Schedule agent-assisted tasks alongside your own, based on how much supervision they need
|
|
34
33
|
- ⏱️ Consider urgency, due dates, and dependencies
|
|
35
34
|
- 🧪 Persisted dry-run previews for reports and timelines
|
|
36
35
|
- ↩️ Undo the latest schedule update
|
|
@@ -41,19 +40,16 @@ Use it if you want to:
|
|
|
41
40
|
- 🎨 Report styling with emoji and extra attributes
|
|
42
41
|
- 🗓️ Block time with iCal or Google Calendar, including all-day events
|
|
43
42
|
|
|
44
|
-
|
|
43
|
+

|
|
45
44
|
|
|
46
|
-
|
|
47
|
-
2. `taskcheck --install` ← setup only
|
|
48
|
-
3. add `estimated` + `time_map` UDAs
|
|
49
|
-
4. edit `~/.config/task/taskcheck.toml` (see [Reference](#Reference))
|
|
50
|
-
5. `taskcheck --schedule`
|
|
51
|
-
6. `taskcheck --undo` restores the scheduling fields changed by the latest run
|
|
45
|
+

|
|
52
46
|
|
|
53
47
|
## How it works
|
|
54
48
|
|
|
55
|
-
Taskcheck reads your pending and waiting tasks, estimates when each one can be worked on, and writes
|
|
56
|
-
|
|
49
|
+
Taskcheck reads your pending and waiting tasks, estimates when each one can be worked on, and writes
|
|
50
|
+
back a schedule. For tasks tagged `+agent`, it treats `estimated` as the agent's work time and
|
|
51
|
+
`agent_weight` as the percentage of that time needing your attention. It respects:
|
|
52
|
+
|
|
57
53
|
- your working hours
|
|
58
54
|
- task duration estimates
|
|
59
55
|
- calendar events and vacations from iCal / Google Calendar
|
|
@@ -64,10 +60,21 @@ It updates Taskwarrior with:
|
|
|
64
60
|
- `completion_date` → when work is expected to finish
|
|
65
61
|
- a warning when a task will miss its due date
|
|
66
62
|
|
|
63
|
+
## Quick start
|
|
64
|
+
|
|
65
|
+
1. `pipx install taskcheck`
|
|
66
|
+
2. `taskcheck --install` ← setup only: registers required Taskwarrior settings inyour taskwarrior config file and offers to create the
|
|
67
|
+
config file; it does not schedule tasks.
|
|
68
|
+
3. edit `~/.config/task/taskcheck.toml` (see [Reference](#Reference))
|
|
69
|
+
4. tune your tasks: add `estimated`, `time_map`, `agent_weight` UDAs and the `+agent` tag (`time_map` and `agent_weight` may use the default value defined in the Taskwarrior settings)
|
|
70
|
+
5. `taskcheck --schedule`
|
|
71
|
+
6. `taskcheck --undo` restores the scheduling fields changed by the latest run
|
|
72
|
+
|
|
67
73
|
### Required UDAs
|
|
68
74
|
|
|
69
75
|
- `estimated`: expected duration in hours
|
|
70
76
|
- `time_map`: which working-hour profile applies to the task, for example `work` or `weekend`
|
|
77
|
+
- `agent_weight`: required for `+agent` tasks; the percentage (0–100) of agent work time that needs your attention. It defaults to 25 when using `taskcheck --install`.
|
|
71
78
|
|
|
72
79
|
Example:
|
|
73
80
|
|
|
@@ -77,6 +84,20 @@ monday = [[9, 12.30], [14, 17]]
|
|
|
77
84
|
tuesday = [[9, 12.30], [14, 17]]
|
|
78
85
|
```
|
|
79
86
|
|
|
87
|
+
### Agent tasks and mixed blocks
|
|
88
|
+
|
|
89
|
+
Mark a task `+agent` when a computer helper will do the work, and set `agent_weight` to show what
|
|
90
|
+
percentage of its work time needs your attention. For example, `10` means you spend about 10 minutes
|
|
91
|
+
interacting with the agent for each hour the agent works. Taskcheck can plan several agent jobs at
|
|
92
|
+
once, fill the time left with your own tasks, and respect waiting dates and dependencies.
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
task add "Implement API" +agent estimated:5h agent_weight:10
|
|
96
|
+
task add "Review results" estimated:1h
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Run `taskcheck --install` to register the `agent_weight` Taskwarrior UDA.
|
|
100
|
+
|
|
80
101
|
### Reports and timelines
|
|
81
102
|
|
|
82
103
|
- `taskcheck -r today` → tasks planned for today
|
|
@@ -84,9 +105,15 @@ tuesday = [[9, 12.30], [14, 17]]
|
|
|
84
105
|
- `taskcheck --timeline 1w` → automatic day/week/month timeline for the next week
|
|
85
106
|
- `taskcheck --timeline eom --zoom week` → weekly timeline through month end
|
|
86
107
|
|
|
87
|
-
Create a preview with `taskcheck --schedule --dry-run`. It does not update Taskwarrior; instead, it
|
|
108
|
+
Create a preview with `taskcheck --schedule --dry-run`. It does not update Taskwarrior; instead, it
|
|
109
|
+
saves the generated schedule to the Taskwarrior data directory. Reuse it with `taskcheck --report
|
|
110
|
+
today --dry-run` or `taskcheck --timeline 1w --dry-run`.
|
|
88
111
|
|
|
89
|
-
Timeline rows follow dotted project levels such as `work.client`. Without `--zoom`, the most
|
|
112
|
+
Timeline rows follow dotted project levels such as `work.client`. Without `--zoom`, the most
|
|
113
|
+
detailed view that fits the terminal width is selected: day, then week, then month. The track uses
|
|
114
|
+
`▶` for a start, `━` for work, `◀` for an end, and `◆` for a deadline. Thin, heavy, and double rules
|
|
115
|
+
separate days, weeks, and months. Project colors are deterministic and adapt to light or dark
|
|
116
|
+
terminals through OSC 11, with config and environment fallbacks.
|
|
90
117
|
|
|
91
118
|
## Reference
|
|
92
119
|
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-

|
|
2
|
-
|
|
3
1
|
A Taskwarrior scheduler for people who want a realistic plan, not a manual to-do list.
|
|
4
2
|
It turns tasks, working hours, and calendar blocks into an actionable schedule — then keeps due dates visible.
|
|
5
3
|
|
|
@@ -8,6 +6,7 @@ Use it if you want to:
|
|
|
8
6
|
- fit tasks into real availability
|
|
9
7
|
- be sure your long-time plan won't go overdue
|
|
10
8
|
- keep Taskwarrior as the source of truth
|
|
9
|
+
- handle your time across multiple tasks carried on by LLMs
|
|
11
10
|
|
|
12
11
|
> [!IMPORTANT]
|
|
13
12
|
> This repo is actively maintained again
|
|
@@ -15,7 +14,7 @@ Use it if you want to:
|
|
|
15
14
|
## Features
|
|
16
15
|
|
|
17
16
|
- ✨ Auto-schedule tasks from working hours + calendar blocks
|
|
18
|
-
-
|
|
17
|
+
- 🧠 Schedule agent-assisted tasks alongside your own, based on how much supervision they need
|
|
19
18
|
- ⏱️ Consider urgency, due dates, and dependencies
|
|
20
19
|
- 🧪 Persisted dry-run previews for reports and timelines
|
|
21
20
|
- ↩️ Undo the latest schedule update
|
|
@@ -26,19 +25,16 @@ Use it if you want to:
|
|
|
26
25
|
- 🎨 Report styling with emoji and extra attributes
|
|
27
26
|
- 🗓️ Block time with iCal or Google Calendar, including all-day events
|
|
28
27
|
|
|
29
|
-
|
|
28
|
+

|
|
30
29
|
|
|
31
|
-
|
|
32
|
-
2. `taskcheck --install` ← setup only
|
|
33
|
-
3. add `estimated` + `time_map` UDAs
|
|
34
|
-
4. edit `~/.config/task/taskcheck.toml` (see [Reference](#Reference))
|
|
35
|
-
5. `taskcheck --schedule`
|
|
36
|
-
6. `taskcheck --undo` restores the scheduling fields changed by the latest run
|
|
30
|
+

|
|
37
31
|
|
|
38
32
|
## How it works
|
|
39
33
|
|
|
40
|
-
Taskcheck reads your pending and waiting tasks, estimates when each one can be worked on, and writes
|
|
41
|
-
|
|
34
|
+
Taskcheck reads your pending and waiting tasks, estimates when each one can be worked on, and writes
|
|
35
|
+
back a schedule. For tasks tagged `+agent`, it treats `estimated` as the agent's work time and
|
|
36
|
+
`agent_weight` as the percentage of that time needing your attention. It respects:
|
|
37
|
+
|
|
42
38
|
- your working hours
|
|
43
39
|
- task duration estimates
|
|
44
40
|
- calendar events and vacations from iCal / Google Calendar
|
|
@@ -49,10 +45,21 @@ It updates Taskwarrior with:
|
|
|
49
45
|
- `completion_date` → when work is expected to finish
|
|
50
46
|
- a warning when a task will miss its due date
|
|
51
47
|
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
1. `pipx install taskcheck`
|
|
51
|
+
2. `taskcheck --install` ← setup only: registers required Taskwarrior settings inyour taskwarrior config file and offers to create the
|
|
52
|
+
config file; it does not schedule tasks.
|
|
53
|
+
3. edit `~/.config/task/taskcheck.toml` (see [Reference](#Reference))
|
|
54
|
+
4. tune your tasks: add `estimated`, `time_map`, `agent_weight` UDAs and the `+agent` tag (`time_map` and `agent_weight` may use the default value defined in the Taskwarrior settings)
|
|
55
|
+
5. `taskcheck --schedule`
|
|
56
|
+
6. `taskcheck --undo` restores the scheduling fields changed by the latest run
|
|
57
|
+
|
|
52
58
|
### Required UDAs
|
|
53
59
|
|
|
54
60
|
- `estimated`: expected duration in hours
|
|
55
61
|
- `time_map`: which working-hour profile applies to the task, for example `work` or `weekend`
|
|
62
|
+
- `agent_weight`: required for `+agent` tasks; the percentage (0–100) of agent work time that needs your attention. It defaults to 25 when using `taskcheck --install`.
|
|
56
63
|
|
|
57
64
|
Example:
|
|
58
65
|
|
|
@@ -62,6 +69,20 @@ monday = [[9, 12.30], [14, 17]]
|
|
|
62
69
|
tuesday = [[9, 12.30], [14, 17]]
|
|
63
70
|
```
|
|
64
71
|
|
|
72
|
+
### Agent tasks and mixed blocks
|
|
73
|
+
|
|
74
|
+
Mark a task `+agent` when a computer helper will do the work, and set `agent_weight` to show what
|
|
75
|
+
percentage of its work time needs your attention. For example, `10` means you spend about 10 minutes
|
|
76
|
+
interacting with the agent for each hour the agent works. Taskcheck can plan several agent jobs at
|
|
77
|
+
once, fill the time left with your own tasks, and respect waiting dates and dependencies.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
task add "Implement API" +agent estimated:5h agent_weight:10
|
|
81
|
+
task add "Review results" estimated:1h
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Run `taskcheck --install` to register the `agent_weight` Taskwarrior UDA.
|
|
85
|
+
|
|
65
86
|
### Reports and timelines
|
|
66
87
|
|
|
67
88
|
- `taskcheck -r today` → tasks planned for today
|
|
@@ -69,9 +90,15 @@ tuesday = [[9, 12.30], [14, 17]]
|
|
|
69
90
|
- `taskcheck --timeline 1w` → automatic day/week/month timeline for the next week
|
|
70
91
|
- `taskcheck --timeline eom --zoom week` → weekly timeline through month end
|
|
71
92
|
|
|
72
|
-
Create a preview with `taskcheck --schedule --dry-run`. It does not update Taskwarrior; instead, it
|
|
93
|
+
Create a preview with `taskcheck --schedule --dry-run`. It does not update Taskwarrior; instead, it
|
|
94
|
+
saves the generated schedule to the Taskwarrior data directory. Reuse it with `taskcheck --report
|
|
95
|
+
today --dry-run` or `taskcheck --timeline 1w --dry-run`.
|
|
73
96
|
|
|
74
|
-
Timeline rows follow dotted project levels such as `work.client`. Without `--zoom`, the most
|
|
97
|
+
Timeline rows follow dotted project levels such as `work.client`. Without `--zoom`, the most
|
|
98
|
+
detailed view that fits the terminal width is selected: day, then week, then month. The track uses
|
|
99
|
+
`▶` for a start, `━` for work, `◀` for an end, and `◆` for a deadline. Thin, heavy, and double rules
|
|
100
|
+
separate days, weeks, and months. Project colors are deterministic and adapt to light or dark
|
|
101
|
+
terminals through OSC 11, with config and environment fallbacks.
|
|
75
102
|
|
|
76
103
|
## Reference
|
|
77
104
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Plan mixed agent/human work inside an ordinary Taskcheck block.
|
|
2
|
+
|
|
3
|
+
All durations are hours. Agent estimates describe elapsed execution time while
|
|
4
|
+
agent_weight is the percentage of that time needing human interaction. A block
|
|
5
|
+
consumes its full elapsed duration from the calendar, not the sum of the task
|
|
6
|
+
estimates. Internal allocations never create scheduling sub-blocks.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from math import isfinite
|
|
11
|
+
|
|
12
|
+
from taskcheck.common import hours_to_decimal
|
|
13
|
+
|
|
14
|
+
_EPSILON = 1e-9
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_agent_weight(task):
|
|
18
|
+
"""Return human-interaction fraction for +agent tasks, else None.
|
|
19
|
+
|
|
20
|
+
A tagged task must specify a percentage from 0 to 100. In particular,
|
|
21
|
+
missing weights must not silently imply free, infinite parallelism.
|
|
22
|
+
"""
|
|
23
|
+
if "agent" not in (task.get("tags") or []):
|
|
24
|
+
return None
|
|
25
|
+
try:
|
|
26
|
+
weight = float(task["agent_weight"])
|
|
27
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
28
|
+
raise ValueError(
|
|
29
|
+
f"Task {task.get('id', task.get('uuid'))}: +agent requires a numeric agent_weight"
|
|
30
|
+
) from exc
|
|
31
|
+
if not isfinite(weight) or not 0 <= weight <= 100:
|
|
32
|
+
raise ValueError(
|
|
33
|
+
f"Task {task.get('id', task.get('uuid'))}: agent_weight must be between 0 and 100"
|
|
34
|
+
)
|
|
35
|
+
return weight / 100
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _eligible(info, pending, date):
|
|
39
|
+
if any(dep in pending for dep in info["task"].get("depends", [])):
|
|
40
|
+
return False
|
|
41
|
+
wait = info["task"].get("wait")
|
|
42
|
+
if wait and date <= datetime.strptime(wait, "%Y%m%dT%H%M%SZ").date():
|
|
43
|
+
return False
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _max_allocation(info, day_offset, block_hours):
|
|
48
|
+
return min(
|
|
49
|
+
float(info["remaining_hours"]),
|
|
50
|
+
float(info["task_time_map"][day_offset]),
|
|
51
|
+
block_hours,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def allocate_agent_block(
|
|
56
|
+
tasks_remaining, sorted_task_ids, day_offset, day_remaining_hours, date, anchor_uuid
|
|
57
|
+
):
|
|
58
|
+
"""Allocate one block anchored by an eligible agent task.
|
|
59
|
+
|
|
60
|
+
Returns (elapsed_hours, {uuid: allocated_task_hours}), and mutates the
|
|
61
|
+
selected task states in-place. None of the individual allocations is a
|
|
62
|
+
calendar sub-block. All agents run within the same elapsed block; human
|
|
63
|
+
work consumes interaction time left unused by the agents.
|
|
64
|
+
|
|
65
|
+
Tasks which depend on anything pending *at block start* cannot join this
|
|
66
|
+
same block, even if that prerequisite finishes within it.
|
|
67
|
+
"""
|
|
68
|
+
anchor = tasks_remaining[anchor_uuid]
|
|
69
|
+
if get_agent_weight(anchor["task"]) is None:
|
|
70
|
+
return 0, {}
|
|
71
|
+
pending = set(tasks_remaining)
|
|
72
|
+
if not _eligible(anchor, pending, date):
|
|
73
|
+
return 0, {}
|
|
74
|
+
block_hours = min(
|
|
75
|
+
day_remaining_hours,
|
|
76
|
+
hours_to_decimal(float(anchor["task"].get("min_block", 2))),
|
|
77
|
+
)
|
|
78
|
+
if block_hours <= _EPSILON:
|
|
79
|
+
return 0, {}
|
|
80
|
+
|
|
81
|
+
allocations = {}
|
|
82
|
+
human_budget = block_hours
|
|
83
|
+
agent_order = [anchor_uuid] + [uuid for uuid in sorted_task_ids if uuid != anchor_uuid]
|
|
84
|
+
for uuid in agent_order:
|
|
85
|
+
info = tasks_remaining.get(uuid)
|
|
86
|
+
if info is None:
|
|
87
|
+
continue
|
|
88
|
+
weight = get_agent_weight(info["task"])
|
|
89
|
+
if weight is None or not _eligible(info, pending, date):
|
|
90
|
+
continue
|
|
91
|
+
agent_cap = min(
|
|
92
|
+
block_hours,
|
|
93
|
+
hours_to_decimal(float(info["task"].get("min_block", 2))),
|
|
94
|
+
)
|
|
95
|
+
duration = _max_allocation(info, day_offset, agent_cap)
|
|
96
|
+
if weight > 0:
|
|
97
|
+
duration = min(duration, max(human_budget, 0) / weight)
|
|
98
|
+
if duration <= _EPSILON:
|
|
99
|
+
continue
|
|
100
|
+
allocations[uuid] = duration
|
|
101
|
+
human_budget = max(0, human_budget - weight * duration)
|
|
102
|
+
|
|
103
|
+
# The anchor must have actual execution time; a zero-length block is invalid.
|
|
104
|
+
if anchor_uuid not in allocations:
|
|
105
|
+
return 0, {}
|
|
106
|
+
|
|
107
|
+
for uuid in sorted_task_ids:
|
|
108
|
+
info = tasks_remaining.get(uuid)
|
|
109
|
+
if info is None or get_agent_weight(info["task"]) is not None:
|
|
110
|
+
continue
|
|
111
|
+
if not _eligible(info, pending, date):
|
|
112
|
+
continue
|
|
113
|
+
# Deliberately allow sub-min_block *allocations*, but only in a mixed
|
|
114
|
+
# block. Standalone human scheduling stays in the legacy path.
|
|
115
|
+
# Honor task-specific chunk caps while allowing allocations *smaller*
|
|
116
|
+
# than that cap only within this mixed block.
|
|
117
|
+
human_cap = min(
|
|
118
|
+
human_budget, hours_to_decimal(float(info["task"].get("min_block", 2)))
|
|
119
|
+
)
|
|
120
|
+
duration = _max_allocation(info, day_offset, human_cap)
|
|
121
|
+
if duration <= _EPSILON:
|
|
122
|
+
continue
|
|
123
|
+
allocations[uuid] = duration
|
|
124
|
+
human_budget = max(0, human_budget - duration)
|
|
125
|
+
if human_budget <= _EPSILON:
|
|
126
|
+
break
|
|
127
|
+
|
|
128
|
+
# Charge only elapsed calendar time actually needed. Otherwise a short
|
|
129
|
+
# agent finishing alone would artificially consume a whole min_block.
|
|
130
|
+
# Interaction and human work must fit inside this elapsed interval, while
|
|
131
|
+
# all agent executions may overlap.
|
|
132
|
+
interaction_hours = sum(
|
|
133
|
+
duration * get_agent_weight(tasks_remaining[uuid]["task"])
|
|
134
|
+
if get_agent_weight(tasks_remaining[uuid]["task"]) is not None
|
|
135
|
+
else duration
|
|
136
|
+
for uuid, duration in allocations.items()
|
|
137
|
+
)
|
|
138
|
+
elapsed_hours = max(
|
|
139
|
+
interaction_hours,
|
|
140
|
+
*(
|
|
141
|
+
duration
|
|
142
|
+
for uuid, duration in allocations.items()
|
|
143
|
+
if get_agent_weight(tasks_remaining[uuid]["task"]) is not None
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
date_str = date.isoformat()
|
|
148
|
+
for uuid, duration in allocations.items():
|
|
149
|
+
info = tasks_remaining[uuid]
|
|
150
|
+
if not info["scheduling"]:
|
|
151
|
+
info["started"] = True
|
|
152
|
+
info["remaining_hours"] = max(0, info["remaining_hours"] - duration)
|
|
153
|
+
# Cached time-map lists may be shared by different tasks; the mutable
|
|
154
|
+
# per-task remaining availability must not alias another task.
|
|
155
|
+
info["task_time_map"] = list(info["task_time_map"])
|
|
156
|
+
info["task_time_map"][day_offset] = max(
|
|
157
|
+
0, info["task_time_map"][day_offset] - duration
|
|
158
|
+
)
|
|
159
|
+
info["scheduling"][date_str] = info["scheduling"].get(date_str, 0) + duration
|
|
160
|
+
return elapsed_hours, allocations
|
|
@@ -148,7 +148,25 @@ def hours_to_pdth(hours):
|
|
|
148
148
|
def get_long_range_time_map(
|
|
149
149
|
time_maps, time_map_names, days_ahead, calendars, verbose=False
|
|
150
150
|
):
|
|
151
|
-
|
|
151
|
+
# The cache must distinguish both the horizon and the input configuration.
|
|
152
|
+
# Otherwise a previous schedule (possibly with a different time map or
|
|
153
|
+
# calendar) can silently give tasks stale daily availability.
|
|
154
|
+
key = (
|
|
155
|
+
tuple(time_map_names),
|
|
156
|
+
days_ahead,
|
|
157
|
+
datetime.today().date(),
|
|
158
|
+
json.dumps(
|
|
159
|
+
{name: time_maps[name] for name in time_map_names},
|
|
160
|
+
sort_keys=True,
|
|
161
|
+
default=str,
|
|
162
|
+
),
|
|
163
|
+
json.dumps(
|
|
164
|
+
calendars,
|
|
165
|
+
sort_keys=True,
|
|
166
|
+
default=lambda value: value.isoformat()
|
|
167
|
+
if hasattr(value, "isoformat") else str(value),
|
|
168
|
+
),
|
|
169
|
+
)
|
|
152
170
|
if key in long_range_time_map:
|
|
153
171
|
task_time_map = long_range_time_map[key]
|
|
154
172
|
else:
|
|
@@ -63,6 +63,9 @@ def install():
|
|
|
63
63
|
("uda.min_block.type", "numeric"),
|
|
64
64
|
("uda.min_block.label", "Minimum Time Block"),
|
|
65
65
|
("uda.min_block.default", "2"),
|
|
66
|
+
("uda.agent_weight.type", "numeric"),
|
|
67
|
+
("uda.agent_weight.label", "Agent Weight (%)"),
|
|
68
|
+
("uda.agent_weight.default", "25"),
|
|
66
69
|
("recurrence.confirmation", "no"),
|
|
67
70
|
]
|
|
68
71
|
|
|
@@ -120,7 +123,7 @@ def install():
|
|
|
120
123
|
"""
|
|
121
124
|
_________________________________________________________________________________________
|
|
122
125
|
Required configuration for taskcheck includes:
|
|
123
|
-
-
|
|
126
|
+
- Taskwarrior UDAs, including `agent_weight` for +agent tasks
|
|
124
127
|
- turning off confirmation for recurring tasks
|
|
125
128
|
|
|
126
129
|
Do you want to continue? [y/N] """
|
|
@@ -6,6 +6,8 @@ from datetime import datetime, timedelta
|
|
|
6
6
|
|
|
7
7
|
from rich.table import Table
|
|
8
8
|
|
|
9
|
+
from taskcheck.agent_blocks import allocate_agent_block, get_agent_weight
|
|
10
|
+
|
|
9
11
|
from taskcheck.undo import create_undo_backup
|
|
10
12
|
from taskcheck.common import (
|
|
11
13
|
AVOID_STATUS,
|
|
@@ -330,11 +332,14 @@ def initialize_task_info(tasks, time_maps, days_ahead, urgency_coefficients, cal
|
|
|
330
332
|
continue
|
|
331
333
|
if "estimated" not in task or "time_map" not in task:
|
|
332
334
|
continue
|
|
335
|
+
get_agent_weight(task) # Validate +agent tasks before scheduling.
|
|
333
336
|
estimated_hours = pdth_to_hours(task["estimated"])
|
|
334
337
|
time_map_names = task["time_map"].split(",")
|
|
335
338
|
task_time_map, today_used_hours = get_long_range_time_map(
|
|
336
339
|
time_maps, time_map_names, days_ahead, calendars
|
|
337
340
|
)
|
|
341
|
+
# The cache returns a shared list. Mutations must be task-local.
|
|
342
|
+
task_time_map = list(task_time_map)
|
|
338
343
|
task_uuid = task["uuid"]
|
|
339
344
|
initial_urgency = float(task.get("urgency", 0))
|
|
340
345
|
estimated_urgency = urgency_estimated(
|
|
@@ -415,6 +420,25 @@ def allocate_time_for_day(
|
|
|
415
420
|
if verbose:
|
|
416
421
|
print(f"Skipping task {info['task']['id']} due to wait date {wait}")
|
|
417
422
|
continue
|
|
423
|
+
if get_agent_weight(info["task"]) is not None:
|
|
424
|
+
# Agent tasks overlap in elapsed time. Charge interactions and
|
|
425
|
+
# human work to the same block's human-time budget.
|
|
426
|
+
elapsed, allocations = allocate_agent_block(
|
|
427
|
+
tasks_remaining, sorted_task_ids, day_offset,
|
|
428
|
+
day_remaining_hours, date, uuid,
|
|
429
|
+
)
|
|
430
|
+
if elapsed > 0:
|
|
431
|
+
day_remaining_hours -= elapsed
|
|
432
|
+
allocated = True
|
|
433
|
+
if verbose:
|
|
434
|
+
print(f"Allocated mixed block ({elapsed:.2f}h): {allocations}")
|
|
435
|
+
for allocated_uuid in allocations:
|
|
436
|
+
allocated_info = tasks_remaining[allocated_uuid]
|
|
437
|
+
if (allocated_info["remaining_hours"] <= 0
|
|
438
|
+
or allocated_info["task_time_map"][day_offset] <= 0):
|
|
439
|
+
del tasks_remaining[allocated_uuid]
|
|
440
|
+
break
|
|
441
|
+
continue
|
|
418
442
|
allocation = allocate_time_to_task(info, day_offset, day_remaining_hours)
|
|
419
443
|
if allocation > 0:
|
|
420
444
|
day_remaining_hours -= allocation
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: taskcheck
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.0
|
|
4
4
|
Summary: Automatic scheduler for taskwarrior
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -13,8 +13,6 @@ Requires-Dist: random-unicode-emoji>=2.8
|
|
|
13
13
|
Requires-Dist: requests>=2.32.3
|
|
14
14
|
Requires-Dist: rich>=13.9.4
|
|
15
15
|
|
|
16
|
-

|
|
17
|
-
|
|
18
16
|
A Taskwarrior scheduler for people who want a realistic plan, not a manual to-do list.
|
|
19
17
|
It turns tasks, working hours, and calendar blocks into an actionable schedule — then keeps due dates visible.
|
|
20
18
|
|
|
@@ -23,6 +21,7 @@ Use it if you want to:
|
|
|
23
21
|
- fit tasks into real availability
|
|
24
22
|
- be sure your long-time plan won't go overdue
|
|
25
23
|
- keep Taskwarrior as the source of truth
|
|
24
|
+
- handle your time across multiple tasks carried on by LLMs
|
|
26
25
|
|
|
27
26
|
> [!IMPORTANT]
|
|
28
27
|
> This repo is actively maintained again
|
|
@@ -30,7 +29,7 @@ Use it if you want to:
|
|
|
30
29
|
## Features
|
|
31
30
|
|
|
32
31
|
- ✨ Auto-schedule tasks from working hours + calendar blocks
|
|
33
|
-
-
|
|
32
|
+
- 🧠 Schedule agent-assisted tasks alongside your own, based on how much supervision they need
|
|
34
33
|
- ⏱️ Consider urgency, due dates, and dependencies
|
|
35
34
|
- 🧪 Persisted dry-run previews for reports and timelines
|
|
36
35
|
- ↩️ Undo the latest schedule update
|
|
@@ -41,19 +40,16 @@ Use it if you want to:
|
|
|
41
40
|
- 🎨 Report styling with emoji and extra attributes
|
|
42
41
|
- 🗓️ Block time with iCal or Google Calendar, including all-day events
|
|
43
42
|
|
|
44
|
-
|
|
43
|
+

|
|
45
44
|
|
|
46
|
-
|
|
47
|
-
2. `taskcheck --install` ← setup only
|
|
48
|
-
3. add `estimated` + `time_map` UDAs
|
|
49
|
-
4. edit `~/.config/task/taskcheck.toml` (see [Reference](#Reference))
|
|
50
|
-
5. `taskcheck --schedule`
|
|
51
|
-
6. `taskcheck --undo` restores the scheduling fields changed by the latest run
|
|
45
|
+

|
|
52
46
|
|
|
53
47
|
## How it works
|
|
54
48
|
|
|
55
|
-
Taskcheck reads your pending and waiting tasks, estimates when each one can be worked on, and writes
|
|
56
|
-
|
|
49
|
+
Taskcheck reads your pending and waiting tasks, estimates when each one can be worked on, and writes
|
|
50
|
+
back a schedule. For tasks tagged `+agent`, it treats `estimated` as the agent's work time and
|
|
51
|
+
`agent_weight` as the percentage of that time needing your attention. It respects:
|
|
52
|
+
|
|
57
53
|
- your working hours
|
|
58
54
|
- task duration estimates
|
|
59
55
|
- calendar events and vacations from iCal / Google Calendar
|
|
@@ -64,10 +60,21 @@ It updates Taskwarrior with:
|
|
|
64
60
|
- `completion_date` → when work is expected to finish
|
|
65
61
|
- a warning when a task will miss its due date
|
|
66
62
|
|
|
63
|
+
## Quick start
|
|
64
|
+
|
|
65
|
+
1. `pipx install taskcheck`
|
|
66
|
+
2. `taskcheck --install` ← setup only: registers required Taskwarrior settings inyour taskwarrior config file and offers to create the
|
|
67
|
+
config file; it does not schedule tasks.
|
|
68
|
+
3. edit `~/.config/task/taskcheck.toml` (see [Reference](#Reference))
|
|
69
|
+
4. tune your tasks: add `estimated`, `time_map`, `agent_weight` UDAs and the `+agent` tag (`time_map` and `agent_weight` may use the default value defined in the Taskwarrior settings)
|
|
70
|
+
5. `taskcheck --schedule`
|
|
71
|
+
6. `taskcheck --undo` restores the scheduling fields changed by the latest run
|
|
72
|
+
|
|
67
73
|
### Required UDAs
|
|
68
74
|
|
|
69
75
|
- `estimated`: expected duration in hours
|
|
70
76
|
- `time_map`: which working-hour profile applies to the task, for example `work` or `weekend`
|
|
77
|
+
- `agent_weight`: required for `+agent` tasks; the percentage (0–100) of agent work time that needs your attention. It defaults to 25 when using `taskcheck --install`.
|
|
71
78
|
|
|
72
79
|
Example:
|
|
73
80
|
|
|
@@ -77,6 +84,20 @@ monday = [[9, 12.30], [14, 17]]
|
|
|
77
84
|
tuesday = [[9, 12.30], [14, 17]]
|
|
78
85
|
```
|
|
79
86
|
|
|
87
|
+
### Agent tasks and mixed blocks
|
|
88
|
+
|
|
89
|
+
Mark a task `+agent` when a computer helper will do the work, and set `agent_weight` to show what
|
|
90
|
+
percentage of its work time needs your attention. For example, `10` means you spend about 10 minutes
|
|
91
|
+
interacting with the agent for each hour the agent works. Taskcheck can plan several agent jobs at
|
|
92
|
+
once, fill the time left with your own tasks, and respect waiting dates and dependencies.
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
task add "Implement API" +agent estimated:5h agent_weight:10
|
|
96
|
+
task add "Review results" estimated:1h
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Run `taskcheck --install` to register the `agent_weight` Taskwarrior UDA.
|
|
100
|
+
|
|
80
101
|
### Reports and timelines
|
|
81
102
|
|
|
82
103
|
- `taskcheck -r today` → tasks planned for today
|
|
@@ -84,9 +105,15 @@ tuesday = [[9, 12.30], [14, 17]]
|
|
|
84
105
|
- `taskcheck --timeline 1w` → automatic day/week/month timeline for the next week
|
|
85
106
|
- `taskcheck --timeline eom --zoom week` → weekly timeline through month end
|
|
86
107
|
|
|
87
|
-
Create a preview with `taskcheck --schedule --dry-run`. It does not update Taskwarrior; instead, it
|
|
108
|
+
Create a preview with `taskcheck --schedule --dry-run`. It does not update Taskwarrior; instead, it
|
|
109
|
+
saves the generated schedule to the Taskwarrior data directory. Reuse it with `taskcheck --report
|
|
110
|
+
today --dry-run` or `taskcheck --timeline 1w --dry-run`.
|
|
88
111
|
|
|
89
|
-
Timeline rows follow dotted project levels such as `work.client`. Without `--zoom`, the most
|
|
112
|
+
Timeline rows follow dotted project levels such as `work.client`. Without `--zoom`, the most
|
|
113
|
+
detailed view that fits the terminal width is selected: day, then week, then month. The track uses
|
|
114
|
+
`▶` for a start, `━` for work, `◀` for an end, and `◆` for a deadline. Thin, heavy, and double rules
|
|
115
|
+
separate days, weeks, and months. Project colors are deterministic and adapt to light or dark
|
|
116
|
+
terminals through OSC 11, with config and environment fallbacks.
|
|
90
117
|
|
|
91
118
|
## Reference
|
|
92
119
|
|
|
@@ -2,6 +2,7 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
taskcheck/__main__.py
|
|
5
|
+
taskcheck/agent_blocks.py
|
|
5
6
|
taskcheck/common.py
|
|
6
7
|
taskcheck/dry_run.py
|
|
7
8
|
taskcheck/google_calendar.py
|
|
@@ -18,6 +19,8 @@ taskcheck.egg-info/dependency_links.txt
|
|
|
18
19
|
taskcheck.egg-info/entry_points.txt
|
|
19
20
|
taskcheck.egg-info/requires.txt
|
|
20
21
|
taskcheck.egg-info/top_level.txt
|
|
22
|
+
tests/test_agent_blocks.py
|
|
23
|
+
tests/test_agent_scheduler_integration.py
|
|
21
24
|
tests/test_common.py
|
|
22
25
|
tests/test_google_calendar.py
|
|
23
26
|
tests/test_ical.py
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
from datetime import date, timedelta
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from taskcheck.agent_blocks import allocate_agent_block, get_agent_weight
|
|
6
|
+
|
|
7
|
+
TODAY = date.today()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def task(name, *, agent=False, weight=None, hours=2, urgency=10, depends=(), wait=None, daily=8):
|
|
11
|
+
record = {"uuid": name, "id": name, "estimated": "PT2H", "min_block": 2}
|
|
12
|
+
if agent:
|
|
13
|
+
record["tags"] = ["agent"]
|
|
14
|
+
if weight is not None:
|
|
15
|
+
record["agent_weight"] = weight
|
|
16
|
+
if depends:
|
|
17
|
+
record["depends"] = list(depends)
|
|
18
|
+
if wait:
|
|
19
|
+
record["wait"] = wait
|
|
20
|
+
return {"task": record, "remaining_hours": hours, "task_time_map": [daily],
|
|
21
|
+
"scheduling": {}, "started": False, "urgency": urgency}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run(items, order=None, block=2):
|
|
25
|
+
ordered = order or sorted(items, key=lambda name: -items[name]["urgency"])
|
|
26
|
+
return allocate_agent_block(items, ordered, 0, block, TODAY, ordered[0])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_mixed_two_agents_and_short_human_tasks():
|
|
30
|
+
items = {
|
|
31
|
+
"a": task("a", agent=True, weight=25),
|
|
32
|
+
"b": task("b", agent=True, weight=35),
|
|
33
|
+
"h1": task("h1", hours=.5),
|
|
34
|
+
"h2": task("h2", hours=.5),
|
|
35
|
+
}
|
|
36
|
+
elapsed, selected = run(items, ["a", "b", "h1", "h2"])
|
|
37
|
+
assert elapsed == 2
|
|
38
|
+
assert selected == pytest.approx({"a": 2, "b": 2, "h1": .5, "h2": .3})
|
|
39
|
+
assert items["h1"]["remaining_hours"] == pytest.approx(0)
|
|
40
|
+
assert items["h2"]["remaining_hours"] == pytest.approx(.2)
|
|
41
|
+
assert all(set(info["scheduling"]) == {TODAY.isoformat()} for info in items.values())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_short_agent_duration_consumes_only_actual_interaction():
|
|
45
|
+
items = {"a": task("a", agent=True, weight=25, hours=1),
|
|
46
|
+
"h": task("h", hours=2)}
|
|
47
|
+
elapsed, allocation = run(items, ["a", "h"])
|
|
48
|
+
assert elapsed == 2
|
|
49
|
+
assert allocation == pytest.approx({"a": 1, "h": 1.75})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_full_weight_agent_excludes_human():
|
|
53
|
+
items = {"a": task("a", agent=True, weight=100),
|
|
54
|
+
"h": task("h", hours=2)}
|
|
55
|
+
_, selected = run(items, ["a", "h"])
|
|
56
|
+
assert selected == {"a": 2}
|
|
57
|
+
assert items["h"]["remaining_hours"] == 2
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_dependencies_cannot_execute_with_agent_in_same_block():
|
|
61
|
+
items = {"a": task("a", agent=True, weight=25),
|
|
62
|
+
"h": task("h", hours=1, depends=["a"]),
|
|
63
|
+
"other": task("other", hours=1)}
|
|
64
|
+
_, selected = run(items, ["a", "h", "other"])
|
|
65
|
+
assert "h" not in selected
|
|
66
|
+
assert selected["other"] == 1
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_waiting_agent_excluded_even_when_urgent():
|
|
70
|
+
tomorrow = (TODAY + timedelta(days=1)).strftime("%Y%m%dT%H%M%SZ")
|
|
71
|
+
items = {"a": task("a", agent=True, weight=25),
|
|
72
|
+
"b": task("b", agent=True, weight=25, wait=tomorrow),
|
|
73
|
+
"h": task("h", hours=2)}
|
|
74
|
+
_, selected = run(items, ["a", "b", "h"])
|
|
75
|
+
assert selected == pytest.approx({"a": 2, "h": 1.5})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_daily_availability_limits_agent_and_human():
|
|
79
|
+
items = {"a": task("a", agent=True, weight=50, daily=.5),
|
|
80
|
+
"h": task("h", hours=2, daily=.75)}
|
|
81
|
+
elapsed, selected = run(items, ["a", "h"])
|
|
82
|
+
assert elapsed == pytest.approx(1)
|
|
83
|
+
assert selected == pytest.approx({"a": .5, "h": .75})
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_agent_capacity_limits_multiple_agents():
|
|
87
|
+
items = {"a": task("a", agent=True, weight=80),
|
|
88
|
+
"b": task("b", agent=True, weight=40)}
|
|
89
|
+
_, selected = run(items, ["a", "b"])
|
|
90
|
+
assert selected == pytest.approx({"a": 2, "b": 1})
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@pytest.mark.parametrize("bad", [None, -1, 101, "bad", float("nan")])
|
|
94
|
+
def test_invalid_weight_is_rejected(bad):
|
|
95
|
+
t = task("a", agent=True, weight=bad)["task"]
|
|
96
|
+
with pytest.raises(ValueError, match="agent_weight"):
|
|
97
|
+
get_agent_weight(t)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_non_agent_does_not_require_weight():
|
|
101
|
+
assert get_agent_weight(task("h")["task"]) is None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_no_real_subblocks_appear_in_scheduling():
|
|
105
|
+
items = {"a": task("a", agent=True, weight=25), "h": task("h", hours=.5)}
|
|
106
|
+
_, _ = run(items, ["a", "h"])
|
|
107
|
+
assert items["a"]["scheduling"] == {TODAY.isoformat(): 2}
|
|
108
|
+
assert items["h"]["scheduling"] == {TODAY.isoformat(): .5}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_short_lone_agent_does_not_reserve_unused_calendar_time():
|
|
112
|
+
items = {"a": task("a", agent=True, weight=25, hours=1)}
|
|
113
|
+
elapsed, selected = run(items, ["a"])
|
|
114
|
+
assert selected == {"a": 1}
|
|
115
|
+
assert elapsed == pytest.approx(1)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_mixed_block_elapsed_reflects_agents_and_supervision_time():
|
|
119
|
+
items = {"a": task("a", agent=True, weight=25, hours=1),
|
|
120
|
+
"h": task("h", hours=1)}
|
|
121
|
+
elapsed, selected = run(items, ["a", "h"])
|
|
122
|
+
assert selected == pytest.approx({"a": 1, "h": 1})
|
|
123
|
+
assert elapsed == pytest.approx(1.25)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_one_minute_human_task_is_valid_inside_mixed_block():
|
|
127
|
+
# min_block is a bound on *outer* chunks; it does not apply to human
|
|
128
|
+
# allocations inside a mixed block.
|
|
129
|
+
items = {"a": task("a", agent=True, weight=10, hours=2),
|
|
130
|
+
"h": task("h", hours=1 / 60)}
|
|
131
|
+
elapsed, selected = run(items, ["a", "h"])
|
|
132
|
+
assert elapsed == pytest.approx(2)
|
|
133
|
+
assert selected["h"] == pytest.approx(1 / 60)
|
|
134
|
+
assert items["h"]["remaining_hours"] == pytest.approx(0)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_one_minute_agent_can_anchor_mixed_block():
|
|
138
|
+
items = {"a": task("a", agent=True, weight=10, hours=1 / 60),
|
|
139
|
+
"h": task("h", hours=.5)}
|
|
140
|
+
elapsed, selected = run(items, ["a", "h"])
|
|
141
|
+
assert selected == pytest.approx({"a": 1 / 60, "h": .5})
|
|
142
|
+
assert elapsed == pytest.approx(.5 + 1 / 600)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_cached_time_map_is_not_mutated_across_tasks():
|
|
146
|
+
# get_long_range_time_map caches its list by name in the upstream module.
|
|
147
|
+
# An independent per-task daily cap is required: a task's execution must
|
|
148
|
+
# not eat another task's cap just because they share the same profile.
|
|
149
|
+
items = {"a": task("a", agent=True, weight=10, hours=1),
|
|
150
|
+
"h": task("h", hours=.5)}
|
|
151
|
+
original_shared_map = [2.0]
|
|
152
|
+
items["a"]["task_time_map"] = original_shared_map
|
|
153
|
+
items["h"]["task_time_map"] = original_shared_map
|
|
154
|
+
elapsed, selected = run(items, ["a", "h"])
|
|
155
|
+
assert selected == pytest.approx({"a": 1, "h": .5})
|
|
156
|
+
assert elapsed == pytest.approx(1)
|
|
157
|
+
assert items["a"]["task_time_map"] == pytest.approx([1])
|
|
158
|
+
assert items["h"]["task_time_map"] == pytest.approx([1.5])
|
|
159
|
+
assert original_shared_map == [2.0]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_many_mixed_blocks_respect_elapsed_and_human_budget():
|
|
163
|
+
import random
|
|
164
|
+
rng = random.Random(765421)
|
|
165
|
+
for _ in range(500):
|
|
166
|
+
weights = [rng.uniform(0, 100) for _ in range(rng.randint(1, 6))]
|
|
167
|
+
items = {
|
|
168
|
+
f"a{i}": task(f"a{i}", agent=True, weight=w,
|
|
169
|
+
hours=rng.uniform(.01, 4), daily=rng.uniform(.1, 3))
|
|
170
|
+
for i, w in enumerate(weights)
|
|
171
|
+
}
|
|
172
|
+
for j in range(rng.randint(0, 5)):
|
|
173
|
+
items[f"h{j}"] = task(f"h{j}", hours=rng.uniform(.01, 4),
|
|
174
|
+
daily=rng.uniform(.1, 3))
|
|
175
|
+
originals = {name: data['remaining_hours'] for name, data in items.items()}
|
|
176
|
+
elapsed, selected = run(items, list(items))
|
|
177
|
+
assert 0 < elapsed <= 2 + 1e-8
|
|
178
|
+
interaction = sum(selected.get(name, 0) * (get_agent_weight(info['task']) or 0)
|
|
179
|
+
if 'agent' in info['task'].get('tags', [])
|
|
180
|
+
else selected.get(name, 0)
|
|
181
|
+
for name, info in items.items())
|
|
182
|
+
assert interaction <= elapsed + 1e-8
|
|
183
|
+
for name, allocated in selected.items():
|
|
184
|
+
assert allocated <= originals[name] + 1e-8
|
|
185
|
+
assert items[name]['remaining_hours'] >= -1e-8
|
|
186
|
+
if get_agent_weight(items[name]['task']) is not None:
|
|
187
|
+
assert allocated <= elapsed + 1e-8
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_human_custom_chunk_limit_is_respected_inside_mixed_block():
|
|
191
|
+
items = {"a": task("a", agent=True, weight=10, hours=2),
|
|
192
|
+
"h": task("h", hours=3)}
|
|
193
|
+
items['h']['task']['min_block'] = 0.3 # Original hh.mm convention: 30 minutes.
|
|
194
|
+
elapsed, selected = run(items, ['a', 'h'])
|
|
195
|
+
assert elapsed == pytest.approx(2)
|
|
196
|
+
assert selected['h'] == pytest.approx(.5)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Integration coverage against taskcheck.parallel's day-allocation loop."""
|
|
2
|
+
from datetime import datetime, timedelta
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from taskcheck.parallel import UrgencyCoefficients, allocate_time_for_day
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
COEFFS = UrgencyCoefficients({"PT2H": 0}, False, 0, 365, 0, 0)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def info(name, *, hours=2, urgency=10, agent=False, weight=None, depends=()):
|
|
13
|
+
t = {"uuid": name, "id": name, "description": name,
|
|
14
|
+
"estimated": "PT2H", "min_block": 2}
|
|
15
|
+
if agent:
|
|
16
|
+
t["tags"] = ["agent"]
|
|
17
|
+
t["agent_weight"] = weight
|
|
18
|
+
if depends:
|
|
19
|
+
t["depends"] = list(depends)
|
|
20
|
+
return {"task": t, "remaining_hours": hours,
|
|
21
|
+
"task_time_map": [2.0, 2.0], "today_used_hours": 0,
|
|
22
|
+
"scheduling": {}, "urgency": urgency,
|
|
23
|
+
"estimated_urgency": 0, "age_urgency": 0, "due_urgency": 0,
|
|
24
|
+
"started": False}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def schedule(tasks, monkeypatch):
|
|
28
|
+
monkeypatch.setattr('taskcheck.parallel.recompute_urgencies', lambda *args, **kwargs: None)
|
|
29
|
+
allocate_time_for_day(tasks, 1, COEFFS, False, 1)
|
|
30
|
+
return (datetime.today().date() + timedelta(days=1)).isoformat()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_mixed_allocation_inside_one_calendar_block(monkeypatch):
|
|
34
|
+
tasks = {"a": info("a", agent=True, weight=25),
|
|
35
|
+
"b": info("b", agent=True, weight=35, urgency=9),
|
|
36
|
+
"h": info("h", hours=.8, urgency=8)}
|
|
37
|
+
day = schedule(tasks, monkeypatch)
|
|
38
|
+
assert tasks["a"]["scheduling"] == {day: 2}
|
|
39
|
+
assert tasks["b"]["scheduling"] == {day: 2}
|
|
40
|
+
assert tasks["h"]["scheduling"] == pytest.approx({day: .8})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_more_urgent_human_keeps_exclusive_block(monkeypatch):
|
|
44
|
+
tasks = {"h": info("h", urgency=20),
|
|
45
|
+
"a": info("a", agent=True, weight=25, urgency=10)}
|
|
46
|
+
day = schedule(tasks, monkeypatch)
|
|
47
|
+
assert tasks["h"]["scheduling"] == {day: 2}
|
|
48
|
+
assert tasks["a"]["scheduling"] == {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_dependencies_wait_for_next_block(monkeypatch):
|
|
52
|
+
tasks = {"a": info("a", agent=True, weight=25, urgency=20),
|
|
53
|
+
"dependent": info("dependent", hours=.5, urgency=18, depends=("a",)),
|
|
54
|
+
"h": info("h", hours=.5, urgency=15)}
|
|
55
|
+
day = schedule(tasks, monkeypatch)
|
|
56
|
+
assert tasks["dependent"]["scheduling"] == {}
|
|
57
|
+
assert tasks["h"]["scheduling"] == {day: .5}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_five_hour_agent_ten_percent_occupies_two_and_half_blocks(monkeypatch):
|
|
61
|
+
# estimated:5h = execution time, NOT supervision time.
|
|
62
|
+
monkeypatch.setattr('taskcheck.parallel.recompute_urgencies', lambda *args, **kwargs: None)
|
|
63
|
+
agent = info('a', hours=5, agent=True, weight=10)
|
|
64
|
+
agent['task']['estimated'] = 'PT5H'
|
|
65
|
+
agent['task_time_map'] = [2.0, 2.0, 2.0, 2.0]
|
|
66
|
+
allocated_days = []
|
|
67
|
+
for offset in range(1, 4):
|
|
68
|
+
allocate_time_for_day({'a': agent}, offset, COEFFS, False, 1)
|
|
69
|
+
day = (datetime.today().date() + timedelta(days=offset)).isoformat()
|
|
70
|
+
allocated_days.append(agent['scheduling'].get(day, 0))
|
|
71
|
+
assert allocated_days == pytest.approx([2, 2, 1])
|
|
72
|
+
assert sum(allocated_days) == pytest.approx(5)
|
|
73
|
+
assert sum(allocated_days) * .10 == pytest.approx(.5)
|
|
74
|
+
assert agent['remaining_hours'] == pytest.approx(0)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_five_hour_agent_and_four_hour_human_fit_in_five_calendar_hours(monkeypatch):
|
|
78
|
+
"""Regression: estimates are agent wall-time, NOT supervision time."""
|
|
79
|
+
monkeypatch.setattr('taskcheck.parallel.recompute_urgencies', lambda *args, **kwargs: None)
|
|
80
|
+
a = info('a', hours=5, agent=True, weight=10, urgency=20)
|
|
81
|
+
a['task']['estimated'] = 'PT5H'
|
|
82
|
+
h = info('h', hours=4, urgency=10)
|
|
83
|
+
for item in (a, h):
|
|
84
|
+
item['task_time_map'] = [0, 5]
|
|
85
|
+
task_info = {'a': a, 'h': h}
|
|
86
|
+
allocate_time_for_day(task_info, 1, COEFFS, False, 1)
|
|
87
|
+
day = (datetime.today().date() + timedelta(days=1)).isoformat()
|
|
88
|
+
assert a['scheduling'][day] == pytest.approx(5)
|
|
89
|
+
assert h['scheduling'][day] == pytest.approx(4)
|
|
90
|
+
assert a['remaining_hours'] == pytest.approx(0)
|
|
91
|
+
assert h['remaining_hours'] == pytest.approx(0)
|
|
92
|
+
# 5h elapsed, with 5h*10%=0.5h supervision + 4h independent work.
|
|
93
|
+
assert a['scheduling'][day] * .10 + h['scheduling'][day] == pytest.approx(4.5)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_initialize_task_info_does_not_share_cached_availability(monkeypatch):
|
|
97
|
+
from taskcheck.parallel import initialize_task_info
|
|
98
|
+
shared = [3., 3.]
|
|
99
|
+
monkeypatch.setattr('taskcheck.parallel.get_long_range_time_map',
|
|
100
|
+
lambda *args: (shared, 0))
|
|
101
|
+
tasks = [
|
|
102
|
+
{'id': 1, 'uuid': 'a', 'estimated': 'PT2H', 'time_map': 'work',
|
|
103
|
+
'tags': ['agent'], 'agent_weight': 25},
|
|
104
|
+
{'id': 2, 'uuid': 'h', 'estimated': 'PT2H', 'time_map': 'work'},
|
|
105
|
+
]
|
|
106
|
+
states = initialize_task_info(tasks, {}, 2, COEFFS, [])
|
|
107
|
+
assert states['a']['task_time_map'] is not states['h']['task_time_map']
|
|
108
|
+
assert states['a']['task_time_map'] is not shared
|
|
109
|
+
states['a']['task_time_map'][0] -= 2
|
|
110
|
+
assert states['h']['task_time_map'] == [3., 3.]
|
|
111
|
+
assert shared == [3., 3.]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_initialize_task_info_rejects_missing_agent_weight(monkeypatch):
|
|
115
|
+
from taskcheck.parallel import initialize_task_info
|
|
116
|
+
tasks = [{'id': 1, 'uuid': 'a', 'estimated': 'PT2H',
|
|
117
|
+
'time_map': 'work', 'tags': ['agent']}]
|
|
118
|
+
with pytest.raises(ValueError, match='agent_weight'):
|
|
119
|
+
initialize_task_info(tasks, {}, 2, COEFFS, [])
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_availability_cache_is_scoped_to_horizon_and_time_map():
|
|
123
|
+
"""A new schedule must not inherit a stale cached horizon or work profile."""
|
|
124
|
+
from taskcheck.common import get_long_range_time_map, long_range_time_map
|
|
125
|
+
long_range_time_map.clear()
|
|
126
|
+
work = {day: [[9, 17]] for day in
|
|
127
|
+
('monday', 'tuesday', 'wednesday', 'thursday', 'friday',
|
|
128
|
+
'saturday', 'sunday')}
|
|
129
|
+
try:
|
|
130
|
+
short, _ = get_long_range_time_map({'work': work}, ['work'], 2, [])
|
|
131
|
+
long, _ = get_long_range_time_map({'work': work}, ['work'], 3, [])
|
|
132
|
+
assert len(short) == 2
|
|
133
|
+
assert len(long) == 3
|
|
134
|
+
shorter_hours = {day: [[9, 12]] for day in work}
|
|
135
|
+
changed, _ = get_long_range_time_map(
|
|
136
|
+
{'work': shorter_hours}, ['work'], 3, [])
|
|
137
|
+
assert changed == [3., 3., 3.]
|
|
138
|
+
finally:
|
|
139
|
+
long_range_time_map.clear()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_install_registers_agent_weight_uda(monkeypatch):
|
|
143
|
+
from taskcheck import install as install_module
|
|
144
|
+
from unittest.mock import Mock
|
|
145
|
+
apply = Mock()
|
|
146
|
+
monkeypatch.setattr(install_module, 'apply_config', apply)
|
|
147
|
+
answers = iter(('yes', 'no', 'no'))
|
|
148
|
+
monkeypatch.setattr('builtins.input', lambda *args: next(answers))
|
|
149
|
+
install_module.install()
|
|
150
|
+
applied = [call.args for call in apply.call_args_list]
|
|
151
|
+
assert ('uda.agent_weight.type', 'numeric') in applied
|
|
152
|
+
assert ('uda.agent_weight.label', 'Agent Weight (%)') in applied
|
|
153
|
+
assert ('uda.agent_weight.default', '25') in applied
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|